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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/lib/alarms/csfloat_trade_pings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<UpdateErrors> {
Expand Down Expand Up @@ -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;
}
60 changes: 60 additions & 0 deletions src/lib/alarms/failed_trade.test.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Comment on lines +50 to +60

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do these need to be functions? Feels like pendingTrade and tradeHistory might be better suited as static variables

71 changes: 71 additions & 0 deletions src/lib/alarms/failed_trade.ts
Original file line number Diff line number Diff line change
@@ -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 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active offer blocks failed matching

Medium Severity

findFailedTrades only matches when steam_offer.state is Active, but status-4 failures happen after the offer is accepted. Steam then reports Accepted, and pingSentTradeOffers can update CSFloat before a notary retry. Unlike rollback matching, this drops the trade permanently after one failed prove or if another party already pinged Accepted.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9a9cf8c. Configure here.

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<number>(
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}`);
}
}
4 changes: 3 additions & 1 deletion src/lib/alarms/notary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ export async function isBackgroundNotaryRollbackEnabled(): Promise<boolean> {
}
}

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,
};
}

Expand All @@ -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,
};
}

Expand Down
5 changes: 3 additions & 2 deletions src/lib/alarms/trade_history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/float_market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down