diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index 3243032a..872fba1b 100644 --- a/src/lib/alarms/csfloat_trade_pings.ts +++ b/src/lib/alarms/csfloat_trade_pings.ts @@ -8,6 +8,7 @@ import {gStore} from '../storage/store'; import {StorageKey} from '../storage/keys'; import {reportBlockedBuyers} from './blocked_users'; import {TradeHistoryStatus} from '../bridge/handlers/trade_history_status'; +import {pingFailedTrades} from './failed_trade'; import {pingRollbackTrades} from './rollback'; import {FetchSlimTrades} from '../bridge/handlers/fetch_slim_trades'; @@ -73,6 +74,7 @@ interface UpdateErrors { trade_offer_error?: string; blocked_buyers_error?: string; rollback_trades_error?: string; + failed_trades_error?: string; } async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): Promise { @@ -119,5 +121,12 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): errors.rollback_trades_error = (e as any).toString(); } + try { + await pingFailedTrades(pendingTrades, tradeHistory); + } catch (e) { + console.error('failed to report failed trades', e); + errors.failed_trades_error = (e as any).toString(); + } + return errors; } diff --git a/src/lib/alarms/failed_trade.test.ts b/src/lib/alarms/failed_trade.test.ts new file mode 100644 index 00000000..71ea0eef --- /dev/null +++ b/src/lib/alarms/failed_trade.test.ts @@ -0,0 +1,60 @@ +import {describe, expect, it} from 'vitest'; +import {TradeHistoryStatus} from '../bridge/handlers/trade_history_status'; +import {SlimTrade, TradeState} from '../types/float_market'; +import {TradeOfferState, TradeStatus} from '../types/steam_constants'; +import {findFailedTrades} from './failed_trade'; + +const steamAssetID = '3899876543210123456'; +const otherPartyID = '76561198000000000'; + +describe('failed Steam trades', () => { + it('matches failed history to a relevant pending trade', () => { + const matches = findFailedTrades([pendingTrade()], [tradeHistory()]); + + expect(matches).toHaveLength(1); + expect(matches[0].csfloatTrade.id).toBe('csfloat-trade-id'); + expect(matches[0].steamTrade.status).toBe(TradeStatus.Failed); + }); + + it('does not match an already recorded failed Steam trade', () => { + const trade = pendingTrade(); + trade.steam_trade_failed_id = 'steam-trade-id'; + + expect(findFailedTrades([trade], [tradeHistory()])).toEqual([]); + }); + + it('does not match a trade whose CSFloat offer is accepted', () => { + const trade = pendingTrade(); + trade.steam_offer.state = TradeOfferState.Accepted; + + expect(findFailedTrades([trade], [tradeHistory()])).toEqual([]); + }); +}); + +function pendingTrade(): SlimTrade { + return { + id: 'csfloat-trade-id', + state: TradeState.PENDING, + seller_id: otherPartyID, + buyer_id: '76561198111111111', + contract: { + item: { + asset_id: steamAssetID, + market_hash_name: 'AK-47 | Redline', + }, + }, + steam_offer: {state: TradeOfferState.Active}, + } as SlimTrade; +} + +function tradeHistory(): TradeHistoryStatus { + return { + trade_id: 'steam-trade-id', + status: TradeStatus.Failed, + other_party_url: `https://steamcommunity.com/profiles/${otherPartyID}`, + other_party_id: otherPartyID, + received_assets: [{asset_id: steamAssetID}], + given_assets: [], + time_init: 123, + }; +} diff --git a/src/lib/alarms/failed_trade.ts b/src/lib/alarms/failed_trade.ts new file mode 100644 index 00000000..9e8c5364 --- /dev/null +++ b/src/lib/alarms/failed_trade.ts @@ -0,0 +1,71 @@ +import {TradeHistoryStatus} from '../bridge/handlers/trade_history_status'; +import {StorageKey} from '../storage/keys'; +import {gStore} from '../storage/store'; +import {SlimTrade, TradeState} from '../types/float_market'; +import {TradeOfferState, TradeStatus} from '../types/steam_constants'; +import {reportTradeError} from './error_report'; +import {isBackgroundNotaryRollbackEnabled, proveTradesInBackground} from './notary'; + +interface FailedTradeInfo { + steamTrade: TradeHistoryStatus; + csfloatTrade: SlimTrade; +} + +export function findFailedTrades(pendingTrades: SlimTrade[], tradeHistory: TradeHistoryStatus[]): FailedTradeInfo[] { + const results: FailedTradeInfo[] = []; + + for (const trade of tradeHistory) { + if (trade.status !== TradeStatus.Failed) { + continue; + } + + const receivedIDs = trade.received_assets.map((asset) => asset.asset_id); + const givenIDs = trade.given_assets.map((asset) => asset.asset_id); + const assetIDs = [...receivedIDs, ...givenIDs]; + + const csfloatTrade = pendingTrades.find( + (pendingTrade) => + pendingTrade.state === TradeState.PENDING && + pendingTrade.steam_offer?.state === TradeOfferState.Active && + pendingTrade.steam_trade_failed_id !== trade.trade_id && + assetIDs.includes(pendingTrade.contract.item.asset_id) && + (trade.other_party_id === pendingTrade.seller_id || trade.other_party_id === pendingTrade.buyer_id) + ); + if (!csfloatTrade) { + continue; + } + + results.push({steamTrade: trade, csfloatTrade}); + } + + return results; +} + +export async function pingFailedTrades(pendingTrades: SlimTrade[], tradeHistory: TradeHistoryStatus[]) { + if (!pendingTrades?.length || !tradeHistory?.length) { + return; + } + + const failedTrades = findFailedTrades(pendingTrades, tradeHistory); + if (failedTrades.length === 0 || !(await isBackgroundNotaryRollbackEnabled())) { + 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 failed-trade notary proof, last failure was less than 60 minutes ago'); + return; + } + + try { + await proveTradesInBackground(failedTrades.map((failedTrade) => failedTrade.steamTrade)); + console.log(`proved ${failedTrades.length} failed trade(s) via notary`); + } catch (e) { + console.error('failed-trade notary proving failed', e); + await gStore.setWithStorage(chrome.storage.local, StorageKey.LAST_NOTARY_BG_PROOF_FAILURE, Date.now()); + reportTradeError(failedTrades[0].csfloatTrade.id, `background extension failed-trade notary failed: ${e}`); + } +} diff --git a/src/lib/alarms/notary.ts b/src/lib/alarms/notary.ts index c0e7c4d5..abbd1bb9 100644 --- a/src/lib/alarms/notary.ts +++ b/src/lib/alarms/notary.ts @@ -21,13 +21,14 @@ export async function isBackgroundNotaryRollbackEnabled(): Promise { } } -function buildProveRequest(trades: TradeHistoryStatus[]): NotaryProveRequest { +export function buildProveRequest(trades: TradeHistoryStatus[]): NotaryProveRequest { if (trades.length === 1) { return { type: ProofType.TRADE_HISTORY, max_trades: 5, start_after_time: trades[0].time_init, navigating_back: true, + include_failed: true, }; } @@ -39,6 +40,7 @@ function buildProveRequest(trades: TradeHistoryStatus[]): NotaryProveRequest { max_trades: MAX_TRADE_HISTORY_FETCH, start_after_time: oldestTimeInit, navigating_back: true, + include_failed: true, }; } diff --git a/src/lib/alarms/trade_history.ts b/src/lib/alarms/trade_history.ts index 3ddfd672..8c136060 100644 --- a/src/lib/alarms/trade_history.ts +++ b/src/lib/alarms/trade_history.ts @@ -50,7 +50,7 @@ export async function pingTradeHistory( async function getTradeHistory(): Promise<{history: TradeHistoryStatus[]; type: TradeHistoryType}> { try { - const history = await getTradeHistoryFromAPI(MAX_TRADE_HISTORY_FETCH); + const history = await getTradeHistoryFromAPI(MAX_TRADE_HISTORY_FETCH, {includeFailed: true}); if (history.length > 0) { // Hedge in case this endpoint gets killed, only return if there are results, fallback to HTML parser return {history, type: TradeHistoryType.API}; @@ -146,8 +146,9 @@ export async function getTradeHistoryFromAPI( (e) => e.status === TradeStatus.Committed || e.status === TradeStatus.Complete || + e.status === TradeStatus.Failed || e.status === TradeStatus.TradeProtectionRollback - ) // Only report exchanged/completed trades or trade-protection rollbacks + ) // Only report exchanged/completed trades or failed/rolled-back trades .filter((e) => !e.time_escrow_end || new Date(parseInt(e.time_escrow_end) * 1000).getTime() < Date.now()) .map((e) => { return { diff --git a/src/lib/types/float_market.ts b/src/lib/types/float_market.ts index 2d098fb1..334eb19e 100644 --- a/src/lib/types/float_market.ts +++ b/src/lib/types/float_market.ts @@ -104,6 +104,7 @@ export interface Trade { state: TradeState; trade_url: string; steam_offer: SteamOffer; + steam_trade_failed_id?: string; wait_for_cancel_ping?: boolean; seller_blocked_buyer_at?: string; buyer_blocked_seller_at?: string;