diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index 872fba1b..5a500432 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'; @@ -10,6 +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 {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'; @@ -101,17 +108,22 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.history_error = (e as any).toString(); } + // 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 { - await pingSentTradeOffers(pendingTrades, steamID); + tradeOffers = await getSentAndReceivedTradeOffersFromAPI(); } catch (e) { - console.error('failed to ping sent trade offer state', e); + console.error('failed to fetch trade offers', e); errors.trade_offer_error = (e as any).toString(); } - try { - await pingCancelTrades(pendingTrades, tradeHistory); - } 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 { @@ -128,5 +140,24 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.failed_trades_error = (e as any).toString(); } + if (tradeOffers) { + // 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), + tradeOffers.received || [] + ); + } 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; } 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..1e187a5d --- /dev/null +++ b/src/lib/alarms/offer_state.test.ts @@ -0,0 +1,87 @@ +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 {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 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 { + return { + 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, sent_at: sentAt}, + wait_for_cancel_ping: waitForCancelPing, + } as SlimTrade; +} + +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('buyer offer state notary candidates', () => { + it('proves when the visible offer state differs from CSFloat', () => { + const t = trade(TradeOfferState.CreatedNeedsConfirmation); + expect(findBuyerOfferStateCandidates([t], [offer(TradeOfferState.Active)], now)).toEqual([ + {csfloatTrade: t, localState: TradeOfferState.Active}, + ]); + }); + + 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 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', () => { + const t = trade(TradeOfferState.CreatedNeedsConfirmation, true); + 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(findBuyerOfferStateCandidates([t], [offer(TradeOfferState.Active)], now)).toEqual([]); + }); + + it('skips accepted offers since trade history owns that transition', () => { + const t = trade(TradeOfferState.Active, true); + 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(findBuyerOfferStateCandidates([t], [], now)).toEqual([]); + }); +}); + +describe('offer state prove request', () => { + it('proves a single offer directly', () => { + expect(buildOfferStateProveRequest([{csfloatTrade: trade(TradeOfferState.Active)}])).toEqual({ + type: ProofType.TRADE_OFFER, + tradeofferid: offerID, + }); + }); + + it('batches multiple offers into one unfiltered GetTradeOffers proof of received offers', () => { + expect( + buildOfferStateProveRequest([ + {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 new file mode 100644 index 00000000..cc6d704a --- /dev/null +++ b/src/lib/alarms/offer_state.ts @@ -0,0 +1,126 @@ +import {OfferStatus} from '../bridge/handlers/trade_offer_status'; +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'; + +// 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; + // State of the offer as seen by the buyer on Steam, undefined if not visible to them + localState?: TradeOfferState; +} + +/** + * 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 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. + * + * @param buyerTrades Pending trades where this user is the buyer + * @param receivedOffers Trade offers received by this user on Steam + */ +export function findBuyerOfferStateCandidates( + buyerTrades: SlimTrade[], + receivedOffers: OfferStatus[], + now = Date.now() +): OfferStateCandidate[] { + const candidates: OfferStateCandidate[] = []; + + for (const trade of buyerTrades) { + if (trade.state !== TradeState.PENDING || !trade.steam_offer?.id) { + continue; + } + + const serverState = trade.steam_offer.state; + 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 diverged = localState !== undefined && localState !== serverState; + const waitingOnCancel = !!trade.wait_for_cancel_ping && localState !== TradeOfferState.Active; + if (!diverged && !waitingOnCancel) { + continue; + } + + if (now - offerLastChangedMs(trade, localOffer) < BUYER_MIN_OFFER_AGE_MS) { + continue; + } + + candidates.push({csfloatTrade: trade, 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 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: false, get_received_offers: true}; +} + +/** + * 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 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 proveBuyerOfferStates(buyerTrades: SlimTrade[], receivedOffers: OfferStatus[]) { + if (!buyerTrades.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 candidates = findBuyerOfferStateCandidates(buyerTrades, receivedOffers); + if (candidates.length === 0) { + return; + } + + 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 f9bfe0e1..0423fafc 100644 --- a/src/lib/alarms/trade_offer.ts +++ b/src/lib/alarms/trade_offer.ts @@ -11,8 +11,22 @@ 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; +} + +/** + * @param tradeOffers Sent + received offers already fetched for this alarm run + */ +export async function pingSentTradeOffers( + pendingTrades: SlimTrade[], + steamID: string | null | undefined, + tradeOffers: SentAndReceivedOffers +) { + const offers = tradeOffers.sent; + const type = TradeOffersType.API; const offersToFind = pendingTrades.reduce( (acc, e) => { @@ -81,15 +95,20 @@ 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 + */ +export async function pingCancelTrades( + pendingTrades: SlimTrade[], + tradeHistory: TradeHistoryStatus[], + tradeOffers: SentAndReceivedOffers +) { 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(); - const allTradeOffers = [...(tradeOffers.sent || []), ...(tradeOffers.received || [])]; for (const trade of pendingTrades) { @@ -281,11 +300,7 @@ async function getSentTradeOffersFromAPI(): Promise { return (data.response?.trade_offers_sent || []).map(offerStateMapper); } -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( 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/types/float_market.ts b/src/lib/types/float_market.ts index 334eb19e..39ee8e65 100644 --- a/src/lib/types/float_market.ts +++ b/src/lib/types/float_market.ts @@ -85,6 +85,10 @@ 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; } export interface Trade {