From 7c95dbc87e3f0cb12809d97ccda2dd6d86f75ac0 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 15:51:49 -0600 Subject: [PATCH 01/11] feat: prove Steam offer state via notary in background Buyers prove GetTradeOffer when their view diverges from CSFloat's offer state; either party proves when a trade waits on a cancel ping. Gated by the offer_state notary meta flag. Ref CSF-1979 --- src/lib/alarms/csfloat_trade_pings.ts | 8 ++ src/lib/alarms/notary.ts | 28 +++-- src/lib/alarms/offer_state.test.ts | 68 +++++++++++ src/lib/alarms/offer_state.ts | 120 +++++++++++++++++++ src/lib/alarms/trade_offer.ts | 2 +- src/lib/bridge/handlers/fetch_notary_meta.ts | 1 + src/lib/storage/keys.ts | 1 + src/lib/types/float_market.ts | 2 + 8 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 src/lib/alarms/offer_state.test.ts create mode 100644 src/lib/alarms/offer_state.ts diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index 872fba1b..fd23ac8e 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -10,6 +10,7 @@ import {reportBlockedBuyers} from './blocked_users'; import {TradeHistoryStatus} from '../bridge/handlers/trade_history_status'; import {pingFailedTrades} from './failed_trade'; import {pingRollbackTrades} from './rollback'; +import {proveOfferStates} from './offer_state'; import {FetchSlimTrades} from '../bridge/handlers/fetch_slim_trades'; export const PING_CSFLOAT_TRADE_STATUS_ALARM_NAME = 'ping_csfloat_trade_status_alarm'; @@ -108,6 +109,13 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.trade_offer_error = (e as any).toString(); } + try { + // Before the untrusted cancel ping so a notarized offer state can settle the trade first + await proveOfferStates(pendingTrades, steamID); + } catch (e) { + console.error('failed to prove offer states', e); + } + try { await pingCancelTrades(pendingTrades, tradeHistory); } catch (e) { diff --git a/src/lib/alarms/notary.ts b/src/lib/alarms/notary.ts index abbd1bb9..4c39edd3 100644 --- a/src/lib/alarms/notary.ts +++ b/src/lib/alarms/notary.ts @@ -1,20 +1,28 @@ import {TradeHistoryStatus} from '../bridge/handlers/trade_history_status'; import {NotaryProve} from '../bridge/handlers/notary_prove'; import {FetchNotaryToken} from '../bridge/handlers/fetch_notary_token'; -import {FetchNotaryMeta} from '../bridge/handlers/fetch_notary_meta'; +import {FetchNotaryMeta, NotaryMeta} from '../bridge/handlers/fetch_notary_meta'; import {ProofType, NotaryProveRequest} from '../notary/types'; import {MAX_TRADE_HISTORY_FETCH} from './constants'; import {isFirefox} from '../utils/detect'; import {environment} from '../../environment'; -export async function isBackgroundNotaryRollbackEnabled(): Promise { +export function isBackgroundNotaryRollbackEnabled(): Promise { + return isBackgroundNotaryEnabled('rollback'); +} + +export function isBackgroundNotaryOfferStateEnabled(): Promise { + return isBackgroundNotaryEnabled('offer_state'); +} + +async function isBackgroundNotaryEnabled(setting: keyof NotaryMeta): Promise { if (isFirefox()) { return false; } try { const meta = await FetchNotaryMeta.handleRequest({}, {}); - return meta.rollback?.background === true; + return meta[setting]?.background === true; } catch (e) { console.error('failed to fetch notary meta', e); return false; @@ -44,14 +52,20 @@ export function buildProveRequest(trades: TradeHistoryStatus[]): NotaryProveRequ }; } -export async function proveTradesInBackground(trades: TradeHistoryStatus[]): Promise { +export function proveTradesInBackground(trades: TradeHistoryStatus[]): Promise { if (trades.length === 0) { - return; + return Promise.resolve(); } + return submitNotaryProof(buildProveRequest(trades)); +} + +/** + * Generates a notary proof for the given Steam request and submits it to CSFloat's trade notary endpoint. + */ +export async function submitNotaryProof(proveRequest: NotaryProveRequest): Promise { const notaryToken = await FetchNotaryToken.handleRequest({}, {}); - const proveRequest = buildProveRequest(trades); - proveRequest.meta = {notary_token: notaryToken.token}; + proveRequest.meta = {...proveRequest.meta, notary_token: notaryToken.token}; const result = await NotaryProve.handleRequest(proveRequest, {}); diff --git a/src/lib/alarms/offer_state.test.ts b/src/lib/alarms/offer_state.test.ts new file mode 100644 index 00000000..093a5377 --- /dev/null +++ b/src/lib/alarms/offer_state.test.ts @@ -0,0 +1,68 @@ +import {describe, expect, it} from 'vitest'; +import {OfferStatus} from '../bridge/handlers/trade_offer_status'; +import {SlimTrade, TradeState} from '../types/float_market'; +import {TradeOfferState} from '../types/steam_constants'; +import {findOfferStateCandidates} from './offer_state'; + +const sellerID = '76561198000000000'; +const buyerID = '76561198111111111'; +const offerID = '9340135296'; + +function trade(state: TradeOfferState, waitForCancelPing = false): SlimTrade { + return { + id: 'csfloat-trade-id', + state: TradeState.PENDING, + seller_id: sellerID, + buyer_id: buyerID, + contract: {item: {asset_id: '3899876543210123456', market_hash_name: 'AK-47 | Redline'}}, + steam_offer: {id: offerID, state}, + wait_for_cancel_ping: waitForCancelPing, + } as SlimTrade; +} + +function offer(state: TradeOfferState): OfferStatus { + return {offer_id: offerID, state}; +} + +describe('offer state notary candidates', () => { + it('proves as buyer when the visible offer state differs from CSFloat', () => { + const t = trade(TradeOfferState.CreatedNeedsConfirmation); + expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], buyerID)).toEqual([ + {csfloatTrade: t, localState: TradeOfferState.Active}, + ]); + }); + + it('proves as buyer when CSFloat thinks the offer is active but it is not visible', () => { + const t = trade(TradeOfferState.Active); + expect(findOfferStateCandidates([t], [], buyerID)).toEqual([{csfloatTrade: t, localState: undefined}]); + }); + + it('does not treat an unconfirmed offer that the buyer cannot see as a divergence', () => { + expect(findOfferStateCandidates([trade(TradeOfferState.CreatedNeedsConfirmation)], [], buyerID)).toEqual([]); + }); + + it('does not prove as seller on divergence alone since seller telemetry already reports it', () => { + const t = trade(TradeOfferState.CreatedNeedsConfirmation); + expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], sellerID)).toEqual([]); + }); + + it('proves for either party when waiting on a cancel ping and the offer is gone', () => { + const t = trade(TradeOfferState.CreatedNeedsConfirmation, true); + expect(findOfferStateCandidates([t], [], sellerID)).toEqual([{csfloatTrade: t, localState: undefined}]); + }); + + it('does not prove when waiting on a cancel ping but the offer is still active', () => { + const t = trade(TradeOfferState.Active, true); + expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], sellerID)).toEqual([]); + }); + + it('skips accepted offers since trade history owns that transition', () => { + const t = trade(TradeOfferState.Active, true); + expect(findOfferStateCandidates([t], [offer(TradeOfferState.Accepted)], buyerID)).toEqual([]); + }); + + it('ignores trades without an annotated offer', () => { + const t = {...trade(TradeOfferState.Active), steam_offer: {}} as SlimTrade; + expect(findOfferStateCandidates([t], [], buyerID)).toEqual([]); + }); +}); diff --git a/src/lib/alarms/offer_state.ts b/src/lib/alarms/offer_state.ts new file mode 100644 index 00000000..88a43c1b --- /dev/null +++ b/src/lib/alarms/offer_state.ts @@ -0,0 +1,120 @@ +import {OfferStatus} from '../bridge/handlers/trade_offer_status'; +import {ProofType} from '../notary/types'; +import {StorageKey} from '../storage/keys'; +import {gStore} from '../storage/store'; +import {SlimTrade, TradeState} from '../types/float_market'; +import {TradeOfferState} from '../types/steam_constants'; +import {reportTradeError} from './error_report'; +import {isBackgroundNotaryOfferStateEnabled, submitNotaryProof} from './notary'; +import {getSentAndReceivedTradeOffersFromAPI} from './trade_offer'; + +// Re-prove the same (trade, locally observed offer state) at most this often +const PROOF_INTERVAL_MS = 6 * 60 * 60 * 1000; + +export interface OfferStateCandidate { + csfloatTrade: SlimTrade; + // State of the offer as seen by this user on Steam, undefined if not visible to them + localState?: TradeOfferState; +} + +/** + * Picks pending trades worth proving: + * - Buyer: the offer state Steam shows them differs from what CSFloat has (seller telemetry offline). + * An offer that is not visible to the buyer is only a divergence if CSFloat thinks it is in progress. + * - Either party: the trade is waiting on a cancel ping and the offer is no longer active. + * Accepted offers are skipped since the trade history proof owns that transition. + */ +export function findOfferStateCandidates( + pendingTrades: SlimTrade[], + offers: OfferStatus[], + steamID: string +): OfferStateCandidate[] { + const candidates: OfferStateCandidate[] = []; + + for (const trade of pendingTrades) { + if (trade.state !== TradeState.PENDING || !trade.steam_offer?.id) { + continue; + } + + const serverState = trade.steam_offer.state; + const localState = offers.find((e) => e.offer_id === trade.steam_offer.id)?.state; + if (serverState === TradeOfferState.Accepted || localState === TradeOfferState.Accepted) { + continue; + } + + const isBuyer = trade.buyer_id === steamID; + const serverThinksVisible = serverState === TradeOfferState.Active || serverState === TradeOfferState.InEscrow; + const buyerDiverged = isBuyer && (localState !== undefined ? localState !== serverState : serverThinksVisible); + const waitingOnCancel = !!trade.wait_for_cancel_ping && localState !== TradeOfferState.Active; + + if (buyerDiverged || waitingOnCancel) { + candidates.push({csfloatTrade: trade, localState}); + } + } + + return candidates; +} + +/** + * Submits notarized GetTradeOffer proofs so CSFloat can update the Steam offer state from trusted data. + * Lets a buyer supply offer state when the seller's telemetry is offline, and gives both parties a way + * to settle a trade that is waiting on a cancel ping without relying on untrusted pings. + */ +export async function proveOfferStates(pendingTrades: SlimTrade[], steamID?: string | null) { + if (!steamID || !pendingTrades.some((e) => e.state === TradeState.PENDING && e.steam_offer?.id)) { + return; + } + + if (!(await isBackgroundNotaryOfferStateEnabled())) { + return; + } + + const lastFailure = await gStore.getWithStorage( + chrome.storage.local, + StorageKey.LAST_NOTARY_BG_PROOF_FAILURE + ); + if (lastFailure && lastFailure > Date.now() - 60 * 60 * 1000) { + console.log('skipping offer state notary proof, last failure was less than 60 minutes ago'); + return; + } + + const tradeOffers = await getSentAndReceivedTradeOffersFromAPI(); + const candidates = findOfferStateCandidates( + pendingTrades, + [...(tradeOffers.sent || []), ...(tradeOffers.received || [])], + steamID + ); + if (candidates.length === 0) { + return; + } + + const now = Date.now(); + const attempts: Record = Object.fromEntries( + Object.entries( + (await gStore.getWithStorage>( + chrome.storage.local, + StorageKey.NOTARY_OFFER_STATE_PROOF_ATTEMPTS + )) || {} + ).filter(([, ts]) => ts > now - PROOF_INTERVAL_MS) + ); + + for (const {csfloatTrade, localState} of candidates) { + const attemptKey = `${csfloatTrade.id}:${localState ?? 'none'}`; + if (attempts[attemptKey]) { + continue; + } + + attempts[attemptKey] = now; + await gStore.setWithStorage(chrome.storage.local, StorageKey.NOTARY_OFFER_STATE_PROOF_ATTEMPTS, attempts); + + try { + await submitNotaryProof({type: ProofType.TRADE_OFFER, tradeofferid: csfloatTrade.steam_offer.id}); + console.log(`proved offer state for trade ${csfloatTrade.id} via notary`); + } catch (e) { + console.error(`offer state notary proving failed for trade ${csfloatTrade.id}`, e); + await gStore.setWithStorage(chrome.storage.local, StorageKey.LAST_NOTARY_BG_PROOF_FAILURE, Date.now()); + reportTradeError(csfloatTrade.id, `background extension offer state notary failed: ${e}`); + return; + } + } +} diff --git a/src/lib/alarms/trade_offer.ts b/src/lib/alarms/trade_offer.ts index f9bfe0e1..b018405b 100644 --- a/src/lib/alarms/trade_offer.ts +++ b/src/lib/alarms/trade_offer.ts @@ -281,7 +281,7 @@ async function getSentTradeOffersFromAPI(): Promise { return (data.response?.trade_offers_sent || []).map(offerStateMapper); } -async function getSentAndReceivedTradeOffersFromAPI(): Promise<{ +export async function getSentAndReceivedTradeOffersFromAPI(): Promise<{ received: OfferStatus[]; sent: OfferStatus[]; steam_id?: string | null; diff --git a/src/lib/bridge/handlers/fetch_notary_meta.ts b/src/lib/bridge/handlers/fetch_notary_meta.ts index ebf8ada6..13ecfcb5 100644 --- a/src/lib/bridge/handlers/fetch_notary_meta.ts +++ b/src/lib/bridge/handlers/fetch_notary_meta.ts @@ -10,6 +10,7 @@ interface NotarySetting { export interface NotaryMeta { rollback: NotarySetting; accepted: NotarySetting; + offer_state: NotarySetting; } export interface FetchNotaryMetaRequest {} diff --git a/src/lib/storage/keys.ts b/src/lib/storage/keys.ts index 07c2386d..e37c72d0 100644 --- a/src/lib/storage/keys.ts +++ b/src/lib/storage/keys.ts @@ -14,6 +14,7 @@ export enum StorageKey { LAST_TRADE_PING_ATTEMPT = 'last_trade_ping_attempt', LAST_TRADE_BLOCKED_PING_ATTEMPT = 'last_trade_blocked_ping_attempt', LAST_NOTARY_BG_PROOF_FAILURE = 'last_notary_bg_proof_failure', + NOTARY_OFFER_STATE_PROOF_ATTEMPTS = 'notary_offer_state_proof_attempts', // `${trade_id}:${local_state}` -> attempt ms PRICE_CACHE = 'price_cache', // Stores market hash name -> price mapping (~0.86MB) SCHEMA_CACHE = 'schema_cache', // Stores the full CSFloat schema payload THRESHOLD_CACHE = 'threshold_cache', // Stores FloatDB rank thresholds diff --git a/src/lib/types/float_market.ts b/src/lib/types/float_market.ts index 334eb19e..698ac1c0 100644 --- a/src/lib/types/float_market.ts +++ b/src/lib/types/float_market.ts @@ -85,6 +85,8 @@ export interface SteamOffer { id: string; state: TradeOfferState; sent_at: string; + // Last time the offer state was written from a notarized GetTradeOffer proof + notarized_at?: string; } export interface Trade { From 1afdce34ad52508b1df0d0cae205757eb7b04b68 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 16:04:44 -0600 Subject: [PATCH 02/11] feat: share one trade offers fetch across ping, offer state proof, and cancel ping; batch GetTradeOffers unfiltered --- src/lib/alarms/csfloat_trade_pings.ts | 22 ++++-- src/lib/alarms/offer_state.test.ts | 77 ++++++++++++++++---- src/lib/alarms/offer_state.ts | 100 +++++++++++++++++--------- src/lib/alarms/trade_offer.ts | 42 ++++++++--- 4 files changed, 177 insertions(+), 64 deletions(-) diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index fd23ac8e..f11a5064 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -1,6 +1,12 @@ import {SlimTrade} from '../types/float_market'; import {pingTradeHistory} from './trade_history'; -import {cancelUnconfirmedTradeOffers, pingCancelTrades, pingSentTradeOffers} from './trade_offer'; +import { + cancelUnconfirmedTradeOffers, + getSentAndReceivedTradeOffersFromAPI, + pingCancelTrades, + pingSentTradeOffers, + SentAndReceivedOffers, +} from './trade_offer'; import {HasPermissions} from '../bridge/handlers/has_permissions'; import {PingExtensionStatus} from '../bridge/handlers/ping_extension_status'; import {AccessToken, getAccessToken} from './access_token'; @@ -102,8 +108,16 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.history_error = (e as any).toString(); } + // One Steam request shared by the offer ping, offer state proofs, and cancel pings + let tradeOffers: SentAndReceivedOffers | null = null; try { - await pingSentTradeOffers(pendingTrades, steamID); + tradeOffers = await getSentAndReceivedTradeOffersFromAPI(); + } catch (e) { + console.error('failed to fetch trade offers', e); + } + + try { + await pingSentTradeOffers(pendingTrades, steamID, tradeOffers); } catch (e) { console.error('failed to ping sent trade offer state', e); errors.trade_offer_error = (e as any).toString(); @@ -111,13 +125,13 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): try { // Before the untrusted cancel ping so a notarized offer state can settle the trade first - await proveOfferStates(pendingTrades, steamID); + await proveOfferStates(pendingTrades, tradeOffers); } catch (e) { console.error('failed to prove offer states', e); } try { - await pingCancelTrades(pendingTrades, tradeHistory); + await pingCancelTrades(pendingTrades, tradeHistory, tradeOffers); } catch (e) { console.error('failed to ping cancel ping trade offers', e); } diff --git a/src/lib/alarms/offer_state.test.ts b/src/lib/alarms/offer_state.test.ts index 093a5377..94d0161e 100644 --- a/src/lib/alarms/offer_state.test.ts +++ b/src/lib/alarms/offer_state.test.ts @@ -1,68 +1,115 @@ import {describe, expect, it} from 'vitest'; import {OfferStatus} from '../bridge/handlers/trade_offer_status'; +import {ProofType} from '../notary/types'; import {SlimTrade, TradeState} from '../types/float_market'; import {TradeOfferState} from '../types/steam_constants'; -import {findOfferStateCandidates} from './offer_state'; +import {BUYER_MIN_OFFER_AGE_MS, buildOfferStateProveRequest, findOfferStateCandidates} from './offer_state'; const sellerID = '76561198000000000'; const buyerID = '76561198111111111'; const offerID = '9340135296'; +const now = 1_800_000_000_000; +// Old enough that the buyer-side minimum age never applies unless a test sets a fresh time_updated +const sentAt = new Date(now - 2 * BUYER_MIN_OFFER_AGE_MS).toISOString(); -function trade(state: TradeOfferState, waitForCancelPing = false): SlimTrade { +function trade(state: TradeOfferState, waitForCancelPing = false, id = 'csfloat-trade-id'): SlimTrade { return { - id: 'csfloat-trade-id', + id, state: TradeState.PENDING, seller_id: sellerID, buyer_id: buyerID, contract: {item: {asset_id: '3899876543210123456', market_hash_name: 'AK-47 | Redline'}}, - steam_offer: {id: offerID, state}, + steam_offer: {id: offerID, state, sent_at: sentAt}, wait_for_cancel_ping: waitForCancelPing, } as SlimTrade; } -function offer(state: TradeOfferState): OfferStatus { - return {offer_id: offerID, state}; +function offer(state: TradeOfferState, ageMs = 2 * BUYER_MIN_OFFER_AGE_MS): OfferStatus { + return {offer_id: offerID, state, time_updated: Math.floor((now - ageMs) / 1000)}; } describe('offer state notary candidates', () => { it('proves as buyer when the visible offer state differs from CSFloat', () => { const t = trade(TradeOfferState.CreatedNeedsConfirmation); - expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], buyerID)).toEqual([ - {csfloatTrade: t, localState: TradeOfferState.Active}, + expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], buyerID, now)).toEqual([ + {csfloatTrade: t, isBuyer: true, localState: TradeOfferState.Active}, ]); }); it('proves as buyer when CSFloat thinks the offer is active but it is not visible', () => { const t = trade(TradeOfferState.Active); - expect(findOfferStateCandidates([t], [], buyerID)).toEqual([{csfloatTrade: t, localState: undefined}]); + expect(findOfferStateCandidates([t], [], buyerID, now)).toEqual([ + {csfloatTrade: t, isBuyer: true, localState: undefined}, + ]); + }); + + it('gives seller telemetry first crack: buyer skips offers that changed under 5 minutes ago', () => { + const t = trade(TradeOfferState.CreatedNeedsConfirmation); + const fresh = offer(TradeOfferState.Active, BUYER_MIN_OFFER_AGE_MS - 1000); + expect(findOfferStateCandidates([t], [fresh], buyerID, now)).toEqual([]); + }); + + it('does not apply the minimum age to the seller', () => { + const t = trade(TradeOfferState.Active, true); + const fresh = offer(TradeOfferState.Canceled, 1000); + expect(findOfferStateCandidates([t], [fresh], sellerID, now)).toHaveLength(1); }); it('does not treat an unconfirmed offer that the buyer cannot see as a divergence', () => { - expect(findOfferStateCandidates([trade(TradeOfferState.CreatedNeedsConfirmation)], [], buyerID)).toEqual([]); + expect(findOfferStateCandidates([trade(TradeOfferState.CreatedNeedsConfirmation)], [], buyerID, now)).toEqual( + [] + ); }); it('does not prove as seller on divergence alone since seller telemetry already reports it', () => { const t = trade(TradeOfferState.CreatedNeedsConfirmation); - expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], sellerID)).toEqual([]); + expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], sellerID, now)).toEqual([]); }); it('proves for either party when waiting on a cancel ping and the offer is gone', () => { const t = trade(TradeOfferState.CreatedNeedsConfirmation, true); - expect(findOfferStateCandidates([t], [], sellerID)).toEqual([{csfloatTrade: t, localState: undefined}]); + expect(findOfferStateCandidates([t], [], sellerID, now)).toEqual([ + {csfloatTrade: t, isBuyer: false, localState: undefined}, + ]); }); it('does not prove when waiting on a cancel ping but the offer is still active', () => { const t = trade(TradeOfferState.Active, true); - expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], sellerID)).toEqual([]); + expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], sellerID, now)).toEqual([]); }); it('skips accepted offers since trade history owns that transition', () => { const t = trade(TradeOfferState.Active, true); - expect(findOfferStateCandidates([t], [offer(TradeOfferState.Accepted)], buyerID)).toEqual([]); + expect(findOfferStateCandidates([t], [offer(TradeOfferState.Accepted)], buyerID, now)).toEqual([]); }); it('ignores trades without an annotated offer', () => { const t = {...trade(TradeOfferState.Active), steam_offer: {}} as SlimTrade; - expect(findOfferStateCandidates([t], [], buyerID)).toEqual([]); + expect(findOfferStateCandidates([t], [], buyerID, now)).toEqual([]); + }); +}); + +describe('offer state prove request', () => { + it('proves a single offer directly', () => { + expect(buildOfferStateProveRequest([{csfloatTrade: trade(TradeOfferState.Active), isBuyer: true}])).toEqual({ + type: ProofType.TRADE_OFFER, + tradeofferid: offerID, + }); + }); + + it('batches multiple offers into one unfiltered GetTradeOffers proof for the roles involved', () => { + expect( + buildOfferStateProveRequest([ + {csfloatTrade: trade(TradeOfferState.Active, false, 'a'), isBuyer: true}, + {csfloatTrade: trade(TradeOfferState.Active, true, 'b'), isBuyer: false}, + ]) + ).toEqual({type: ProofType.TRADE_OFFERS, get_sent_offers: true, get_received_offers: true}); + + expect( + buildOfferStateProveRequest([ + {csfloatTrade: trade(TradeOfferState.Active, false, 'a'), isBuyer: true}, + {csfloatTrade: trade(TradeOfferState.Active, false, 'b'), isBuyer: true}, + ]) + ).toEqual({type: ProofType.TRADE_OFFERS, get_sent_offers: false, get_received_offers: true}); }); }); diff --git a/src/lib/alarms/offer_state.ts b/src/lib/alarms/offer_state.ts index 88a43c1b..0f56ee33 100644 --- a/src/lib/alarms/offer_state.ts +++ b/src/lib/alarms/offer_state.ts @@ -1,18 +1,21 @@ import {OfferStatus} from '../bridge/handlers/trade_offer_status'; -import {ProofType} from '../notary/types'; +import {NotaryProveRequest, ProofType} from '../notary/types'; import {StorageKey} from '../storage/keys'; import {gStore} from '../storage/store'; import {SlimTrade, TradeState} from '../types/float_market'; import {TradeOfferState} from '../types/steam_constants'; import {reportTradeError} from './error_report'; import {isBackgroundNotaryOfferStateEnabled, submitNotaryProof} from './notary'; -import {getSentAndReceivedTradeOffersFromAPI} from './trade_offer'; +import {allOffers, SentAndReceivedOffers} from './trade_offer'; // Re-prove the same (trade, locally observed offer state) at most this often const PROOF_INTERVAL_MS = 6 * 60 * 60 * 1000; +// Buyers only prove offers that changed at least this long ago, giving the seller's telemetry first crack +export const BUYER_MIN_OFFER_AGE_MS = 5 * 60 * 1000; export interface OfferStateCandidate { csfloatTrade: SlimTrade; + isBuyer: boolean; // State of the offer as seen by this user on Steam, undefined if not visible to them localState?: TradeOfferState; } @@ -22,12 +25,14 @@ export interface OfferStateCandidate { * - Buyer: the offer state Steam shows them differs from what CSFloat has (seller telemetry offline). * An offer that is not visible to the buyer is only a divergence if CSFloat thinks it is in progress. * - Either party: the trade is waiting on a cancel ping and the offer is no longer active. + * Buyers skip offers that changed less than BUYER_MIN_OFFER_AGE_MS ago so the seller's own ping can resolve it. * Accepted offers are skipped since the trade history proof owns that transition. */ export function findOfferStateCandidates( pendingTrades: SlimTrade[], offers: OfferStatus[], - steamID: string + steamID: string, + now = Date.now() ): OfferStateCandidate[] { const candidates: OfferStateCandidate[] = []; @@ -37,7 +42,8 @@ export function findOfferStateCandidates( } const serverState = trade.steam_offer.state; - const localState = offers.find((e) => e.offer_id === trade.steam_offer.id)?.state; + const localOffer = offers.find((e) => e.offer_id === trade.steam_offer.id); + const localState = localOffer?.state; if (serverState === TradeOfferState.Accepted || localState === TradeOfferState.Accepted) { continue; } @@ -46,21 +52,54 @@ export function findOfferStateCandidates( const serverThinksVisible = serverState === TradeOfferState.Active || serverState === TradeOfferState.InEscrow; const buyerDiverged = isBuyer && (localState !== undefined ? localState !== serverState : serverThinksVisible); const waitingOnCancel = !!trade.wait_for_cancel_ping && localState !== TradeOfferState.Active; + if (!buyerDiverged && !waitingOnCancel) { + continue; + } - if (buyerDiverged || waitingOnCancel) { - candidates.push({csfloatTrade: trade, localState}); + if (isBuyer && now - offerLastChangedMs(trade, localOffer) < BUYER_MIN_OFFER_AGE_MS) { + continue; } + + candidates.push({csfloatTrade: trade, isBuyer, localState}); } return candidates; } +// Steam's time_updated if the offer is visible, otherwise the last change CSFloat knows about +function offerLastChangedMs(trade: SlimTrade, localOffer?: OfferStatus): number { + if (localOffer?.time_updated) { + return localOffer.time_updated * 1000; + } + + return new Date(trade.steam_offer.updated_at || trade.steam_offer.sent_at || trade.created_at).getTime(); +} + +/** + * One GetTradeOffer proof for a single candidate, otherwise a single GetTradeOffers proof covering every + * candidate. Same unfiltered dump the untrusted ping reads, so the server sees the offers we evaluated. + */ +export function buildOfferStateProveRequest(candidates: OfferStateCandidate[]): NotaryProveRequest { + if (candidates.length === 1) { + return {type: ProofType.TRADE_OFFER, tradeofferid: candidates[0].csfloatTrade.steam_offer.id}; + } + + return { + type: ProofType.TRADE_OFFERS, + get_sent_offers: candidates.some((c) => !c.isBuyer), + get_received_offers: candidates.some((c) => c.isBuyer), + }; +} + /** - * Submits notarized GetTradeOffer proofs so CSFloat can update the Steam offer state from trusted data. + * Submits notarized trade offer proofs so CSFloat can update the Steam offer state from trusted data. * Lets a buyer supply offer state when the seller's telemetry is offline, and gives both parties a way * to settle a trade that is waiting on a cancel ping without relying on untrusted pings. + * + * @param tradeOffers Sent + received offers already fetched for this alarm run */ -export async function proveOfferStates(pendingTrades: SlimTrade[], steamID?: string | null) { +export async function proveOfferStates(pendingTrades: SlimTrade[], tradeOffers: SentAndReceivedOffers | null) { + const steamID = tradeOffers?.steam_id; if (!steamID || !pendingTrades.some((e) => e.state === TradeState.PENDING && e.steam_offer?.id)) { return; } @@ -78,16 +117,6 @@ export async function proveOfferStates(pendingTrades: SlimTrade[], steamID?: str return; } - const tradeOffers = await getSentAndReceivedTradeOffersFromAPI(); - const candidates = findOfferStateCandidates( - pendingTrades, - [...(tradeOffers.sent || []), ...(tradeOffers.received || [])], - steamID - ); - if (candidates.length === 0) { - return; - } - const now = Date.now(); const attempts: Record = Object.fromEntries( Object.entries( @@ -98,23 +127,24 @@ export async function proveOfferStates(pendingTrades: SlimTrade[], steamID?: str ).filter(([, ts]) => ts > now - PROOF_INTERVAL_MS) ); - for (const {csfloatTrade, localState} of candidates) { - const attemptKey = `${csfloatTrade.id}:${localState ?? 'none'}`; - if (attempts[attemptKey]) { - continue; - } + const candidates = findOfferStateCandidates(pendingTrades, allOffers(tradeOffers), steamID, now).filter( + (c) => !attempts[`${c.csfloatTrade.id}:${c.localState ?? 'none'}`] + ); + if (candidates.length === 0) { + return; + } - attempts[attemptKey] = now; - await gStore.setWithStorage(chrome.storage.local, StorageKey.NOTARY_OFFER_STATE_PROOF_ATTEMPTS, attempts); - - try { - await submitNotaryProof({type: ProofType.TRADE_OFFER, tradeofferid: csfloatTrade.steam_offer.id}); - console.log(`proved offer state for trade ${csfloatTrade.id} via notary`); - } catch (e) { - console.error(`offer state notary proving failed for trade ${csfloatTrade.id}`, e); - await gStore.setWithStorage(chrome.storage.local, StorageKey.LAST_NOTARY_BG_PROOF_FAILURE, Date.now()); - reportTradeError(csfloatTrade.id, `background extension offer state notary failed: ${e}`); - return; - } + for (const c of candidates) { + attempts[`${c.csfloatTrade.id}:${c.localState ?? 'none'}`] = now; + } + await gStore.setWithStorage(chrome.storage.local, StorageKey.NOTARY_OFFER_STATE_PROOF_ATTEMPTS, attempts); + + try { + await submitNotaryProof(buildOfferStateProveRequest(candidates)); + console.log(`proved offer state for ${candidates.length} trade(s) via notary`); + } catch (e) { + console.error('offer state notary proving failed', e); + await gStore.setWithStorage(chrome.storage.local, StorageKey.LAST_NOTARY_BG_PROOF_FAILURE, Date.now()); + reportTradeError(candidates[0].csfloatTrade.id, `background extension offer state notary failed: ${e}`); } } diff --git a/src/lib/alarms/trade_offer.ts b/src/lib/alarms/trade_offer.ts index b018405b..7d6340db 100644 --- a/src/lib/alarms/trade_offer.ts +++ b/src/lib/alarms/trade_offer.ts @@ -11,8 +11,27 @@ import {HasPermissions} from '../bridge/handlers/has_permissions'; import {convertSteamID32To64} from '../utils/userinfo'; import {TradeHistoryStatus} from '../bridge/handlers/trade_history_status'; -export async function pingSentTradeOffers(pendingTrades: SlimTrade[], steamID?: string | null) { - const {offers, type} = await getSentTradeOffers(); +export interface SentAndReceivedOffers { + received: OfferStatus[]; + sent: OfferStatus[]; + steam_id?: string | null; +} + +export function allOffers(tradeOffers: SentAndReceivedOffers | null): OfferStatus[] { + return [...(tradeOffers?.sent || []), ...(tradeOffers?.received || [])]; +} + +/** + * @param tradeOffers Sent + received offers already fetched for this alarm run, falls back to fetching sent offers + */ +export async function pingSentTradeOffers( + pendingTrades: SlimTrade[], + steamID?: string | null, + tradeOffers?: SentAndReceivedOffers | null +) { + const {offers, type} = tradeOffers + ? {offers: tradeOffers.sent, type: TradeOffersType.API} + : await getSentTradeOffers(); const offersToFind = pendingTrades.reduce( (acc, e) => { @@ -81,16 +100,23 @@ export async function pingSentTradeOffers(pendingTrades: SlimTrade[], steamID?: } } -export async function pingCancelTrades(pendingTrades: SlimTrade[], tradeHistory: TradeHistoryStatus[]) { +/** + * @param tradeOffers Sent + received offers already fetched for this alarm run, fetched here if absent + */ +export async function pingCancelTrades( + pendingTrades: SlimTrade[], + tradeHistory: TradeHistoryStatus[], + tradeOffers?: SentAndReceivedOffers | null +) { const hasWaitForCancelPing = pendingTrades.find((e) => e.state === TradeState.PENDING && e.wait_for_cancel_ping); if (!hasWaitForCancelPing) { // Nothing to process/ping, exit return; } - const tradeOffers = await getSentAndReceivedTradeOffersFromAPI(); + tradeOffers = tradeOffers || (await getSentAndReceivedTradeOffersFromAPI()); - const allTradeOffers = [...(tradeOffers.sent || []), ...(tradeOffers.received || [])]; + const allTradeOffers = allOffers(tradeOffers); for (const trade of pendingTrades) { if (trade.state !== TradeState.PENDING) { @@ -281,11 +307,7 @@ async function getSentTradeOffersFromAPI(): Promise { return (data.response?.trade_offers_sent || []).map(offerStateMapper); } -export async function getSentAndReceivedTradeOffersFromAPI(): Promise<{ - received: OfferStatus[]; - sent: OfferStatus[]; - steam_id?: string | null; -}> { +export async function getSentAndReceivedTradeOffersFromAPI(): Promise { const access = await getAccessToken(); const resp = await fetch( From 5672e8632338657ec269d1191da6d87f312f93d3 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 16:24:38 -0600 Subject: [PATCH 03/11] feat: add updated_at to SteamOffer for buyer min offer age --- src/lib/types/float_market.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/types/float_market.ts b/src/lib/types/float_market.ts index 698ac1c0..39ee8e65 100644 --- a/src/lib/types/float_market.ts +++ b/src/lib/types/float_market.ts @@ -85,6 +85,8 @@ export interface SteamOffer { id: string; state: TradeOfferState; sent_at: string; + // Steam's time_updated for the offer as last reported to CSFloat + updated_at?: string; // Last time the offer state was written from a notarized GetTradeOffer proof notarized_at?: string; } From 13858641df4e6eb052882bc4ef6963c365564ef3 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 18:17:25 -0600 Subject: [PATCH 04/11] refactor: skip offer pings and proofs when the shared trade offers fetch fails instead of refetching --- src/lib/alarms/csfloat_trade_pings.ts | 39 ++++++++++++++------------- src/lib/alarms/offer_state.ts | 4 +-- src/lib/alarms/trade_offer.ts | 21 +++++++-------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index f11a5064..2a784809 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -108,32 +108,35 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.history_error = (e as any).toString(); } - // One Steam request shared by the offer ping, offer state proofs, and cancel pings + // One Steam request shared by the offer ping, offer state proofs, and cancel pings; if it fails, none of them can run let tradeOffers: SentAndReceivedOffers | null = null; try { tradeOffers = await getSentAndReceivedTradeOffersFromAPI(); } catch (e) { console.error('failed to fetch trade offers', e); - } - - try { - await pingSentTradeOffers(pendingTrades, steamID, tradeOffers); - } catch (e) { - console.error('failed to ping sent trade offer state', e); errors.trade_offer_error = (e as any).toString(); } - try { - // Before the untrusted cancel ping so a notarized offer state can settle the trade first - await proveOfferStates(pendingTrades, tradeOffers); - } catch (e) { - console.error('failed to prove offer states', e); - } - - try { - await pingCancelTrades(pendingTrades, tradeHistory, tradeOffers); - } catch (e) { - console.error('failed to ping cancel ping trade offers', e); + if (tradeOffers) { + try { + await pingSentTradeOffers(pendingTrades, steamID, tradeOffers); + } catch (e) { + console.error('failed to ping sent trade offer state', e); + errors.trade_offer_error = (e as any).toString(); + } + + try { + // Before the untrusted cancel ping so a notarized offer state can settle the trade first + await proveOfferStates(pendingTrades, tradeOffers); + } catch (e) { + console.error('failed to prove offer states', e); + } + + try { + await pingCancelTrades(pendingTrades, tradeHistory, tradeOffers); + } catch (e) { + console.error('failed to ping cancel ping trade offers', e); + } } try { diff --git a/src/lib/alarms/offer_state.ts b/src/lib/alarms/offer_state.ts index 0f56ee33..71f0064b 100644 --- a/src/lib/alarms/offer_state.ts +++ b/src/lib/alarms/offer_state.ts @@ -98,8 +98,8 @@ export function buildOfferStateProveRequest(candidates: OfferStateCandidate[]): * * @param tradeOffers Sent + received offers already fetched for this alarm run */ -export async function proveOfferStates(pendingTrades: SlimTrade[], tradeOffers: SentAndReceivedOffers | null) { - const steamID = tradeOffers?.steam_id; +export async function proveOfferStates(pendingTrades: SlimTrade[], tradeOffers: SentAndReceivedOffers) { + const steamID = tradeOffers.steam_id; if (!steamID || !pendingTrades.some((e) => e.state === TradeState.PENDING && e.steam_offer?.id)) { return; } diff --git a/src/lib/alarms/trade_offer.ts b/src/lib/alarms/trade_offer.ts index 7d6340db..8d3d40fd 100644 --- a/src/lib/alarms/trade_offer.ts +++ b/src/lib/alarms/trade_offer.ts @@ -17,21 +17,20 @@ export interface SentAndReceivedOffers { steam_id?: string | null; } -export function allOffers(tradeOffers: SentAndReceivedOffers | null): OfferStatus[] { - return [...(tradeOffers?.sent || []), ...(tradeOffers?.received || [])]; +export function allOffers(tradeOffers: SentAndReceivedOffers): OfferStatus[] { + return [...(tradeOffers.sent || []), ...(tradeOffers.received || [])]; } /** - * @param tradeOffers Sent + received offers already fetched for this alarm run, falls back to fetching sent offers + * @param tradeOffers Sent + received offers already fetched for this alarm run */ export async function pingSentTradeOffers( pendingTrades: SlimTrade[], - steamID?: string | null, - tradeOffers?: SentAndReceivedOffers | null + steamID: string | null | undefined, + tradeOffers: SentAndReceivedOffers ) { - const {offers, type} = tradeOffers - ? {offers: tradeOffers.sent, type: TradeOffersType.API} - : await getSentTradeOffers(); + const offers = tradeOffers.sent; + const type = TradeOffersType.API; const offersToFind = pendingTrades.reduce( (acc, e) => { @@ -101,12 +100,12 @@ export async function pingSentTradeOffers( } /** - * @param tradeOffers Sent + received offers already fetched for this alarm run, fetched here if absent + * @param tradeOffers Sent + received offers already fetched for this alarm run */ export async function pingCancelTrades( pendingTrades: SlimTrade[], tradeHistory: TradeHistoryStatus[], - tradeOffers?: SentAndReceivedOffers | null + tradeOffers: SentAndReceivedOffers ) { const hasWaitForCancelPing = pendingTrades.find((e) => e.state === TradeState.PENDING && e.wait_for_cancel_ping); if (!hasWaitForCancelPing) { @@ -114,8 +113,6 @@ export async function pingCancelTrades( return; } - tradeOffers = tradeOffers || (await getSentAndReceivedTradeOffersFromAPI()); - const allTradeOffers = allOffers(tradeOffers); for (const trade of pendingTrades) { From e524e7417c00405d32a87d1afd1b5d6ea23f64de Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 18:18:21 -0600 Subject: [PATCH 05/11] refactor: prove offer states last so a slow or failed proof never delays telemetry pings --- src/lib/alarms/csfloat_trade_pings.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index 2a784809..b874993d 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -108,7 +108,7 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.history_error = (e as any).toString(); } - // One Steam request shared by the offer ping, offer state proofs, and cancel pings; if it fails, none of them can run + // One Steam request shared by the offer ping, cancel pings, and offer state proofs; if it fails, none of them can run let tradeOffers: SentAndReceivedOffers | null = null; try { tradeOffers = await getSentAndReceivedTradeOffersFromAPI(); @@ -125,13 +125,6 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.trade_offer_error = (e as any).toString(); } - try { - // Before the untrusted cancel ping so a notarized offer state can settle the trade first - await proveOfferStates(pendingTrades, tradeOffers); - } catch (e) { - console.error('failed to prove offer states', e); - } - try { await pingCancelTrades(pendingTrades, tradeHistory, tradeOffers); } catch (e) { @@ -153,5 +146,14 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.failed_trades_error = (e as any).toString(); } + // Last since proving is slow and can fail, it must not hold up the untrusted telemetry above + if (tradeOffers) { + try { + await proveOfferStates(pendingTrades, tradeOffers); + } catch (e) { + console.error('failed to prove offer states', e); + } + } + return errors; } From 196486de508fad488d54b75689a9c70436a0a9cd Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 18:28:06 -0600 Subject: [PATCH 06/11] refactor: run offer state proof then cancel ping at the end of the alarm --- src/lib/alarms/csfloat_trade_pings.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index b874993d..c9222f60 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -124,12 +124,6 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): console.error('failed to ping sent trade offer state', e); errors.trade_offer_error = (e as any).toString(); } - - try { - await pingCancelTrades(pendingTrades, tradeHistory, tradeOffers); - } catch (e) { - console.error('failed to ping cancel ping trade offers', e); - } } try { @@ -146,13 +140,20 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.failed_trades_error = (e as any).toString(); } - // Last since proving is slow and can fail, it must not hold up the untrusted telemetry above if (tradeOffers) { + // Proving is slow and can fail, so it runs after the telemetry above. It runs before the cancel ping + // so a notarized offer state lands before the untrusted "offer is gone" signal for the same trade. try { await proveOfferStates(pendingTrades, tradeOffers); } catch (e) { console.error('failed to prove offer states', e); } + + try { + await pingCancelTrades(pendingTrades, tradeHistory, tradeOffers); + } catch (e) { + console.error('failed to ping cancel ping trade offers', e); + } } return errors; From dae93f43fd8e555e97dad1c1e7e60fdeb9c8050a Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 18:54:44 -0600 Subject: [PATCH 07/11] refactor: prove offer states for buyer-side trades only, seller pings already cover the rest --- src/lib/alarms/csfloat_trade_pings.ts | 7 ++- src/lib/alarms/offer_state.test.ts | 64 +++++++++----------------- src/lib/alarms/offer_state.ts | 65 +++++++++++++-------------- 3 files changed, 55 insertions(+), 81 deletions(-) diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index c9222f60..b14a3706 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -16,7 +16,7 @@ import {reportBlockedBuyers} from './blocked_users'; import {TradeHistoryStatus} from '../bridge/handlers/trade_history_status'; import {pingFailedTrades} from './failed_trade'; import {pingRollbackTrades} from './rollback'; -import {proveOfferStates} from './offer_state'; +import {proveBuyerOfferStates} from './offer_state'; import {FetchSlimTrades} from '../bridge/handlers/fetch_slim_trades'; export const PING_CSFLOAT_TRADE_STATUS_ALARM_NAME = 'ping_csfloat_trade_status_alarm'; @@ -144,7 +144,10 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): // Proving is slow and can fail, so it runs after the telemetry above. It runs before the cancel ping // so a notarized offer state lands before the untrusted "offer is gone" signal for the same trade. try { - await proveOfferStates(pendingTrades, tradeOffers); + await proveBuyerOfferStates( + pendingTrades.filter((t) => t.buyer_id === tradeOffers.steam_id), + tradeOffers.received || [] + ); } catch (e) { console.error('failed to prove offer states', e); } diff --git a/src/lib/alarms/offer_state.test.ts b/src/lib/alarms/offer_state.test.ts index 94d0161e..a47418ea 100644 --- a/src/lib/alarms/offer_state.test.ts +++ b/src/lib/alarms/offer_state.test.ts @@ -3,13 +3,13 @@ import {OfferStatus} from '../bridge/handlers/trade_offer_status'; import {ProofType} from '../notary/types'; import {SlimTrade, TradeState} from '../types/float_market'; import {TradeOfferState} from '../types/steam_constants'; -import {BUYER_MIN_OFFER_AGE_MS, buildOfferStateProveRequest, findOfferStateCandidates} from './offer_state'; +import {BUYER_MIN_OFFER_AGE_MS, buildOfferStateProveRequest, findBuyerOfferStateCandidates} from './offer_state'; const sellerID = '76561198000000000'; const buyerID = '76561198111111111'; const offerID = '9340135296'; const now = 1_800_000_000_000; -// Old enough that the buyer-side minimum age never applies unless a test sets a fresh time_updated +// Old enough that the minimum offer age never applies unless a test sets a fresh time_updated const sentAt = new Date(now - 2 * BUYER_MIN_OFFER_AGE_MS).toISOString(); function trade(state: TradeOfferState, waitForCancelPing = false, id = 'csfloat-trade-id'): SlimTrade { @@ -28,87 +28,63 @@ function offer(state: TradeOfferState, ageMs = 2 * BUYER_MIN_OFFER_AGE_MS): Offe return {offer_id: offerID, state, time_updated: Math.floor((now - ageMs) / 1000)}; } -describe('offer state notary candidates', () => { - it('proves as buyer when the visible offer state differs from CSFloat', () => { +describe('buyer offer state notary candidates', () => { + it('proves when the visible offer state differs from CSFloat', () => { const t = trade(TradeOfferState.CreatedNeedsConfirmation); - expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], buyerID, now)).toEqual([ - {csfloatTrade: t, isBuyer: true, localState: TradeOfferState.Active}, + expect(findBuyerOfferStateCandidates([t], [offer(TradeOfferState.Active)], now)).toEqual([ + {csfloatTrade: t, localState: TradeOfferState.Active}, ]); }); - it('proves as buyer when CSFloat thinks the offer is active but it is not visible', () => { + it('proves when CSFloat thinks the offer is active but it is not visible', () => { const t = trade(TradeOfferState.Active); - expect(findOfferStateCandidates([t], [], buyerID, now)).toEqual([ - {csfloatTrade: t, isBuyer: true, localState: undefined}, - ]); + expect(findBuyerOfferStateCandidates([t], [], now)).toEqual([{csfloatTrade: t, localState: undefined}]); }); - it('gives seller telemetry first crack: buyer skips offers that changed under 5 minutes ago', () => { + it('gives seller telemetry first crack: skips offers that changed under 5 minutes ago', () => { const t = trade(TradeOfferState.CreatedNeedsConfirmation); const fresh = offer(TradeOfferState.Active, BUYER_MIN_OFFER_AGE_MS - 1000); - expect(findOfferStateCandidates([t], [fresh], buyerID, now)).toEqual([]); - }); - - it('does not apply the minimum age to the seller', () => { - const t = trade(TradeOfferState.Active, true); - const fresh = offer(TradeOfferState.Canceled, 1000); - expect(findOfferStateCandidates([t], [fresh], sellerID, now)).toHaveLength(1); + expect(findBuyerOfferStateCandidates([t], [fresh], now)).toEqual([]); }); it('does not treat an unconfirmed offer that the buyer cannot see as a divergence', () => { - expect(findOfferStateCandidates([trade(TradeOfferState.CreatedNeedsConfirmation)], [], buyerID, now)).toEqual( - [] - ); - }); - - it('does not prove as seller on divergence alone since seller telemetry already reports it', () => { - const t = trade(TradeOfferState.CreatedNeedsConfirmation); - expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], sellerID, now)).toEqual([]); + expect(findBuyerOfferStateCandidates([trade(TradeOfferState.CreatedNeedsConfirmation)], [], now)).toEqual([]); }); - it('proves for either party when waiting on a cancel ping and the offer is gone', () => { + it('proves when waiting on a cancel ping and the offer is gone', () => { const t = trade(TradeOfferState.CreatedNeedsConfirmation, true); - expect(findOfferStateCandidates([t], [], sellerID, now)).toEqual([ - {csfloatTrade: t, isBuyer: false, localState: undefined}, - ]); + expect(findBuyerOfferStateCandidates([t], [], now)).toEqual([{csfloatTrade: t, localState: undefined}]); }); it('does not prove when waiting on a cancel ping but the offer is still active', () => { const t = trade(TradeOfferState.Active, true); - expect(findOfferStateCandidates([t], [offer(TradeOfferState.Active)], sellerID, now)).toEqual([]); + expect(findBuyerOfferStateCandidates([t], [offer(TradeOfferState.Active)], now)).toEqual([]); }); it('skips accepted offers since trade history owns that transition', () => { const t = trade(TradeOfferState.Active, true); - expect(findOfferStateCandidates([t], [offer(TradeOfferState.Accepted)], buyerID, now)).toEqual([]); + expect(findBuyerOfferStateCandidates([t], [offer(TradeOfferState.Accepted)], now)).toEqual([]); }); it('ignores trades without an annotated offer', () => { const t = {...trade(TradeOfferState.Active), steam_offer: {}} as SlimTrade; - expect(findOfferStateCandidates([t], [], buyerID, now)).toEqual([]); + expect(findBuyerOfferStateCandidates([t], [], now)).toEqual([]); }); }); describe('offer state prove request', () => { it('proves a single offer directly', () => { - expect(buildOfferStateProveRequest([{csfloatTrade: trade(TradeOfferState.Active), isBuyer: true}])).toEqual({ + expect(buildOfferStateProveRequest([{csfloatTrade: trade(TradeOfferState.Active)}])).toEqual({ type: ProofType.TRADE_OFFER, tradeofferid: offerID, }); }); - it('batches multiple offers into one unfiltered GetTradeOffers proof for the roles involved', () => { - expect( - buildOfferStateProveRequest([ - {csfloatTrade: trade(TradeOfferState.Active, false, 'a'), isBuyer: true}, - {csfloatTrade: trade(TradeOfferState.Active, true, 'b'), isBuyer: false}, - ]) - ).toEqual({type: ProofType.TRADE_OFFERS, get_sent_offers: true, get_received_offers: true}); - + it('batches multiple offers into one unfiltered GetTradeOffers proof of received offers', () => { expect( buildOfferStateProveRequest([ - {csfloatTrade: trade(TradeOfferState.Active, false, 'a'), isBuyer: true}, - {csfloatTrade: trade(TradeOfferState.Active, false, 'b'), isBuyer: true}, + {csfloatTrade: trade(TradeOfferState.Active, false, 'a')}, + {csfloatTrade: trade(TradeOfferState.Active, true, 'b')}, ]) ).toEqual({type: ProofType.TRADE_OFFERS, get_sent_offers: false, get_received_offers: true}); }); diff --git a/src/lib/alarms/offer_state.ts b/src/lib/alarms/offer_state.ts index 71f0064b..7276f31e 100644 --- a/src/lib/alarms/offer_state.ts +++ b/src/lib/alarms/offer_state.ts @@ -6,61 +6,59 @@ import {SlimTrade, TradeState} from '../types/float_market'; import {TradeOfferState} from '../types/steam_constants'; import {reportTradeError} from './error_report'; import {isBackgroundNotaryOfferStateEnabled, submitNotaryProof} from './notary'; -import {allOffers, SentAndReceivedOffers} from './trade_offer'; // Re-prove the same (trade, locally observed offer state) at most this often const PROOF_INTERVAL_MS = 6 * 60 * 60 * 1000; -// Buyers only prove offers that changed at least this long ago, giving the seller's telemetry first crack +// Only prove offers that changed at least this long ago, giving the seller's telemetry first crack export const BUYER_MIN_OFFER_AGE_MS = 5 * 60 * 1000; export interface OfferStateCandidate { csfloatTrade: SlimTrade; - isBuyer: boolean; - // State of the offer as seen by this user on Steam, undefined if not visible to them + // State of the offer as seen by the buyer on Steam, undefined if not visible to them localState?: TradeOfferState; } /** - * Picks pending trades worth proving: - * - Buyer: the offer state Steam shows them differs from what CSFloat has (seller telemetry offline). - * An offer that is not visible to the buyer is only a divergence if CSFloat thinks it is in progress. - * - Either party: the trade is waiting on a cancel ping and the offer is no longer active. - * Buyers skip offers that changed less than BUYER_MIN_OFFER_AGE_MS ago so the seller's own ping can resolve it. + * Picks the buyer's pending trades worth proving: the offer state Steam shows them differs from what + * CSFloat has (seller telemetry offline), or the trade is waiting on a cancel ping and the offer is not active. + * An offer that is not visible to the buyer is only a divergence if CSFloat thinks it is in progress. + * Offers that changed less than BUYER_MIN_OFFER_AGE_MS ago are skipped so the seller's own ping can resolve it. * Accepted offers are skipped since the trade history proof owns that transition. + * + * @param buyerTrades Pending trades where this user is the buyer + * @param receivedOffers Trade offers received by this user on Steam */ -export function findOfferStateCandidates( - pendingTrades: SlimTrade[], - offers: OfferStatus[], - steamID: string, +export function findBuyerOfferStateCandidates( + buyerTrades: SlimTrade[], + receivedOffers: OfferStatus[], now = Date.now() ): OfferStateCandidate[] { const candidates: OfferStateCandidate[] = []; - for (const trade of pendingTrades) { + for (const trade of buyerTrades) { if (trade.state !== TradeState.PENDING || !trade.steam_offer?.id) { continue; } const serverState = trade.steam_offer.state; - const localOffer = offers.find((e) => e.offer_id === trade.steam_offer.id); + const localOffer = receivedOffers.find((e) => e.offer_id === trade.steam_offer.id); const localState = localOffer?.state; if (serverState === TradeOfferState.Accepted || localState === TradeOfferState.Accepted) { continue; } - const isBuyer = trade.buyer_id === steamID; const serverThinksVisible = serverState === TradeOfferState.Active || serverState === TradeOfferState.InEscrow; - const buyerDiverged = isBuyer && (localState !== undefined ? localState !== serverState : serverThinksVisible); + const diverged = localState !== undefined ? localState !== serverState : serverThinksVisible; const waitingOnCancel = !!trade.wait_for_cancel_ping && localState !== TradeOfferState.Active; - if (!buyerDiverged && !waitingOnCancel) { + if (!diverged && !waitingOnCancel) { continue; } - if (isBuyer && now - offerLastChangedMs(trade, localOffer) < BUYER_MIN_OFFER_AGE_MS) { + if (now - offerLastChangedMs(trade, localOffer) < BUYER_MIN_OFFER_AGE_MS) { continue; } - candidates.push({csfloatTrade: trade, isBuyer, localState}); + candidates.push({csfloatTrade: trade, localState}); } return candidates; @@ -76,31 +74,28 @@ function offerLastChangedMs(trade: SlimTrade, localOffer?: OfferStatus): number } /** - * One GetTradeOffer proof for a single candidate, otherwise a single GetTradeOffers proof covering every - * candidate. Same unfiltered dump the untrusted ping reads, so the server sees the offers we evaluated. + * One GetTradeOffer proof for a single candidate, otherwise a single GetTradeOffers proof of the buyer's + * received offers. Same unfiltered dump the untrusted ping reads, so the server sees the offers we evaluated. */ export function buildOfferStateProveRequest(candidates: OfferStateCandidate[]): NotaryProveRequest { if (candidates.length === 1) { return {type: ProofType.TRADE_OFFER, tradeofferid: candidates[0].csfloatTrade.steam_offer.id}; } - return { - type: ProofType.TRADE_OFFERS, - get_sent_offers: candidates.some((c) => !c.isBuyer), - get_received_offers: candidates.some((c) => c.isBuyer), - }; + return {type: ProofType.TRADE_OFFERS, get_sent_offers: false, get_received_offers: true}; } /** - * Submits notarized trade offer proofs so CSFloat can update the Steam offer state from trusted data. - * Lets a buyer supply offer state when the seller's telemetry is offline, and gives both parties a way - * to settle a trade that is waiting on a cancel ping without relying on untrusted pings. + * Submits notarized trade offer proofs for trades where this user is the buyer, so CSFloat can update the + * Steam offer state from trusted data when the seller's telemetry is offline. + * Seller-side proofs are deliberately not sent: pingSentTradeOffers already reports the same state and the + * server does not yet prefer notarized state over it, so proving would be redundant work. * - * @param tradeOffers Sent + received offers already fetched for this alarm run + * @param buyerTrades Pending trades where this user is the buyer + * @param receivedOffers Trade offers received by this user on Steam, already fetched for this alarm run */ -export async function proveOfferStates(pendingTrades: SlimTrade[], tradeOffers: SentAndReceivedOffers) { - const steamID = tradeOffers.steam_id; - if (!steamID || !pendingTrades.some((e) => e.state === TradeState.PENDING && e.steam_offer?.id)) { +export async function proveBuyerOfferStates(buyerTrades: SlimTrade[], receivedOffers: OfferStatus[]) { + if (!buyerTrades.some((e) => e.state === TradeState.PENDING && e.steam_offer?.id)) { return; } @@ -127,7 +122,7 @@ export async function proveOfferStates(pendingTrades: SlimTrade[], tradeOffers: ).filter(([, ts]) => ts > now - PROOF_INTERVAL_MS) ); - const candidates = findOfferStateCandidates(pendingTrades, allOffers(tradeOffers), steamID, now).filter( + const candidates = findBuyerOfferStateCandidates(buyerTrades, receivedOffers, now).filter( (c) => !attempts[`${c.csfloatTrade.id}:${c.localState ?? 'none'}`] ); if (candidates.length === 0) { From 4f9f5c537b3107cfd2108a0576770d661dfca7de Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 18:58:19 -0600 Subject: [PATCH 08/11] refactor: an offer the buyer cannot see is not a divergence, cancel-ping wait covers it --- src/lib/alarms/offer_state.test.ts | 8 ++------ src/lib/alarms/offer_state.ts | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/lib/alarms/offer_state.test.ts b/src/lib/alarms/offer_state.test.ts index a47418ea..1e187a5d 100644 --- a/src/lib/alarms/offer_state.test.ts +++ b/src/lib/alarms/offer_state.test.ts @@ -36,19 +36,15 @@ describe('buyer offer state notary candidates', () => { ]); }); - it('proves when CSFloat thinks the offer is active but it is not visible', () => { - const t = trade(TradeOfferState.Active); - expect(findBuyerOfferStateCandidates([t], [], now)).toEqual([{csfloatTrade: t, localState: undefined}]); - }); - it('gives seller telemetry first crack: skips offers that changed under 5 minutes ago', () => { const t = trade(TradeOfferState.CreatedNeedsConfirmation); const fresh = offer(TradeOfferState.Active, BUYER_MIN_OFFER_AGE_MS - 1000); expect(findBuyerOfferStateCandidates([t], [fresh], now)).toEqual([]); }); - it('does not treat an unconfirmed offer that the buyer cannot see as a divergence', () => { + it('does not treat an offer the buyer cannot see as a divergence until waiting on a cancel ping', () => { expect(findBuyerOfferStateCandidates([trade(TradeOfferState.CreatedNeedsConfirmation)], [], now)).toEqual([]); + expect(findBuyerOfferStateCandidates([trade(TradeOfferState.Active)], [], now)).toEqual([]); }); it('proves when waiting on a cancel ping and the offer is gone', () => { diff --git a/src/lib/alarms/offer_state.ts b/src/lib/alarms/offer_state.ts index 7276f31e..a00a0dc4 100644 --- a/src/lib/alarms/offer_state.ts +++ b/src/lib/alarms/offer_state.ts @@ -21,7 +21,8 @@ export interface OfferStateCandidate { /** * Picks the buyer's pending trades worth proving: the offer state Steam shows them differs from what * CSFloat has (seller telemetry offline), or the trade is waiting on a cancel ping and the offer is not active. - * An offer that is not visible to the buyer is only a divergence if CSFloat thinks it is in progress. + * An offer the buyer cannot see is not a divergence on its own: the proof of a hidden offer is empty, and + * "offer is gone" is already covered once the trade is waiting on a cancel ping. * Offers that changed less than BUYER_MIN_OFFER_AGE_MS ago are skipped so the seller's own ping can resolve it. * Accepted offers are skipped since the trade history proof owns that transition. * @@ -47,8 +48,7 @@ export function findBuyerOfferStateCandidates( continue; } - const serverThinksVisible = serverState === TradeOfferState.Active || serverState === TradeOfferState.InEscrow; - const diverged = localState !== undefined ? localState !== serverState : serverThinksVisible; + const diverged = localState !== undefined && localState !== serverState; const waitingOnCancel = !!trade.wait_for_cancel_ping && localState !== TradeOfferState.Active; if (!diverged && !waitingOnCancel) { continue; From 3d79fe437dab4988ea0d71903367c2fb958d85e0 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 20:56:10 -0600 Subject: [PATCH 09/11] docs: proving is a few seconds, not slow --- src/lib/alarms/csfloat_trade_pings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index b14a3706..5a500432 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -141,8 +141,8 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): } if (tradeOffers) { - // Proving is slow and can fail, so it runs after the telemetry above. It runs before the cancel ping - // so a notarized offer state lands before the untrusted "offer is gone" signal for the same trade. + // Proving takes a few seconds and depends on the notary, so it runs after the telemetry above. It runs + // before the cancel ping so a notarized offer state lands before the untrusted "offer is gone" signal. try { await proveBuyerOfferStates( pendingTrades.filter((t) => t.buyer_id === tradeOffers.steam_id), From 1e114f84c6f929715a9da60f0a4e9e5dc5aeb197 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 21:10:53 -0600 Subject: [PATCH 10/11] refactor: drop per-trade offer state proof throttle, rely on shared failure cooldown like other provers --- src/lib/alarms/offer_state.ts | 21 +-------------------- src/lib/storage/keys.ts | 1 - 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/lib/alarms/offer_state.ts b/src/lib/alarms/offer_state.ts index a00a0dc4..cc6d704a 100644 --- a/src/lib/alarms/offer_state.ts +++ b/src/lib/alarms/offer_state.ts @@ -7,8 +7,6 @@ import {TradeOfferState} from '../types/steam_constants'; import {reportTradeError} from './error_report'; import {isBackgroundNotaryOfferStateEnabled, submitNotaryProof} from './notary'; -// Re-prove the same (trade, locally observed offer state) at most this often -const PROOF_INTERVAL_MS = 6 * 60 * 60 * 1000; // Only prove offers that changed at least this long ago, giving the seller's telemetry first crack export const BUYER_MIN_OFFER_AGE_MS = 5 * 60 * 1000; @@ -112,28 +110,11 @@ export async function proveBuyerOfferStates(buyerTrades: SlimTrade[], receivedOf return; } - const now = Date.now(); - const attempts: Record = Object.fromEntries( - Object.entries( - (await gStore.getWithStorage>( - chrome.storage.local, - StorageKey.NOTARY_OFFER_STATE_PROOF_ATTEMPTS - )) || {} - ).filter(([, ts]) => ts > now - PROOF_INTERVAL_MS) - ); - - const candidates = findBuyerOfferStateCandidates(buyerTrades, receivedOffers, now).filter( - (c) => !attempts[`${c.csfloatTrade.id}:${c.localState ?? 'none'}`] - ); + const candidates = findBuyerOfferStateCandidates(buyerTrades, receivedOffers); if (candidates.length === 0) { return; } - for (const c of candidates) { - attempts[`${c.csfloatTrade.id}:${c.localState ?? 'none'}`] = now; - } - await gStore.setWithStorage(chrome.storage.local, StorageKey.NOTARY_OFFER_STATE_PROOF_ATTEMPTS, attempts); - try { await submitNotaryProof(buildOfferStateProveRequest(candidates)); console.log(`proved offer state for ${candidates.length} trade(s) via notary`); diff --git a/src/lib/storage/keys.ts b/src/lib/storage/keys.ts index e37c72d0..07c2386d 100644 --- a/src/lib/storage/keys.ts +++ b/src/lib/storage/keys.ts @@ -14,7 +14,6 @@ export enum StorageKey { LAST_TRADE_PING_ATTEMPT = 'last_trade_ping_attempt', LAST_TRADE_BLOCKED_PING_ATTEMPT = 'last_trade_blocked_ping_attempt', LAST_NOTARY_BG_PROOF_FAILURE = 'last_notary_bg_proof_failure', - NOTARY_OFFER_STATE_PROOF_ATTEMPTS = 'notary_offer_state_proof_attempts', // `${trade_id}:${local_state}` -> attempt ms PRICE_CACHE = 'price_cache', // Stores market hash name -> price mapping (~0.86MB) SCHEMA_CACHE = 'schema_cache', // Stores the full CSFloat schema payload THRESHOLD_CACHE = 'threshold_cache', // Stores FloatDB rank thresholds From b5c76effc647c837ec6debbbae2a574146b5ed34 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 21:20:50 -0600 Subject: [PATCH 11/11] refactor: inline single-use allOffers helper --- src/lib/alarms/trade_offer.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/alarms/trade_offer.ts b/src/lib/alarms/trade_offer.ts index 8d3d40fd..0423fafc 100644 --- a/src/lib/alarms/trade_offer.ts +++ b/src/lib/alarms/trade_offer.ts @@ -17,10 +17,6 @@ export interface SentAndReceivedOffers { steam_id?: string | null; } -export function allOffers(tradeOffers: SentAndReceivedOffers): OfferStatus[] { - return [...(tradeOffers.sent || []), ...(tradeOffers.received || [])]; -} - /** * @param tradeOffers Sent + received offers already fetched for this alarm run */ @@ -113,7 +109,7 @@ export async function pingCancelTrades( return; } - const allTradeOffers = allOffers(tradeOffers); + const allTradeOffers = [...(tradeOffers.sent || []), ...(tradeOffers.received || [])]; for (const trade of pendingTrades) { if (trade.state !== TradeState.PENDING) {