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
116 changes: 116 additions & 0 deletions src/lib/alarms/accepted_trade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {describe, expect, it} from 'vitest';
import {TradeHistoryStatus} from '../bridge/handlers/trade_history_status';
import {SlimTrade, TradeState} from '../types/float_market';
import {TradeStatus} from '../types/steam_constants';
import {ACCEPTED_PROOF_RETRY_MS, filterDueForProof, findAcceptedTrades} from './accepted_trade';

const assetID = '3899876543210123456';
const sellerID = '76561198000000000';
const buyerID = '76561198111111111';
// accepted_at 100s, so Steam trades initiated at >= 100 are bound to this sale
const acceptedAt = new Date(100 * 1000).toISOString();

const pendingTrade = {
id: 'csfloat-trade-id',
state: TradeState.PENDING,
seller_id: sellerID,
buyer_id: buyerID,
accepted_at: acceptedAt,
contract: {item: {asset_id: assetID, market_hash_name: 'AK-47 | Redline'}},
} as SlimTrade;

function steamTrade(overrides: Partial<TradeHistoryStatus> = {}): TradeHistoryStatus {
return {
trade_id: 'steam-trade-id',
status: TradeStatus.Complete,
other_party_url: `https://steamcommunity.com/profiles/${buyerID}`,
other_party_id: buyerID,
received_assets: [],
given_assets: [{asset_id: assetID}],
time_init: 120,
time_settlement: 1000,
...overrides,
};
}

describe('findAcceptedTrades', () => {
it('matches a complete in-protection sale sent to the buyer', () => {
const matches = findAcceptedTrades([pendingTrade], [steamTrade()], sellerID);

expect(matches).toHaveLength(1);
expect(matches[0].csfloatTrade.id).toBe('csfloat-trade-id');
expect(matches[0].steamTrade.trade_id).toBe('steam-trade-id');
});

it('accepts committed status', () => {
expect(
findAcceptedTrades([pendingTrade], [steamTrade({status: TradeStatus.Committed})], sellerID)
).toHaveLength(1);
});

it('skips sales that are not pending, not accepted, already proven, or not sold by this user', () => {
const history = [steamTrade()];

expect(findAcceptedTrades([{...pendingTrade, state: TradeState.VERIFIED}], history, sellerID)).toEqual([]);
expect(findAcceptedTrades([{...pendingTrade, accepted_at: undefined}], history, sellerID)).toEqual([]);
expect(findAcceptedTrades([{...pendingTrade, notary_accepted_at: acceptedAt}], history, sellerID)).toEqual([]);
expect(findAcceptedTrades([pendingTrade], history, buyerID)).toEqual([]);
});

it('skips Steam entries that are rolled back, outside protection, to another party, or pre-acceptance', () => {
expect(findAcceptedTrades([pendingTrade], [steamTrade({rollback_trade: 'orig'})], sellerID)).toEqual([]);
expect(findAcceptedTrades([pendingTrade], [steamTrade({time_settlement: undefined})], sellerID)).toEqual([]);
expect(findAcceptedTrades([pendingTrade], [steamTrade({other_party_id: sellerID})], sellerID)).toEqual([]);
expect(findAcceptedTrades([pendingTrade], [steamTrade({time_init: 99})], sellerID)).toEqual([]);
expect(
findAcceptedTrades(
[pendingTrade],
[steamTrade({given_assets: [], received_assets: [{asset_id: assetID}]})],
sellerID
)
).toEqual([]);
});

it('uses the most recent attempt so a later rollback blocks an older acceptance', () => {
const accepted = steamTrade({trade_id: 'accepted', time_init: 120});
const rollback = steamTrade({
trade_id: 'rollback',
status: TradeStatus.TradeProtectionRollback,
time_init: 130,
});
const failedFirst = steamTrade({trade_id: 'failed', status: TradeStatus.Failed, time_init: 110});

expect(findAcceptedTrades([pendingTrade], [accepted, rollback], sellerID)).toEqual([]);
expect(findAcceptedTrades([pendingTrade], [rollback, accepted], sellerID)).toEqual([]);
expect(findAcceptedTrades([pendingTrade], [failedFirst, accepted], sellerID)[0]?.steamTrade.trade_id).toBe(
'accepted'
);
});
});

describe('filterDueForProof', () => {
const now = 10_000_000_000;
const match = {csfloatTrade: pendingTrade, steamTrade: steamTrade()};
const other = {csfloatTrade: {...pendingTrade, id: 'other'}, steamTrade: steamTrade()};

it('is due when never attempted or attempted before the retry window', () => {
expect(filterDueForProof([match], {}, now).due).toEqual([match]);
expect(filterDueForProof([match], {[pendingTrade.id]: now - ACCEPTED_PROOF_RETRY_MS}, now).due).toEqual([
match,
]);
});

it('is not due when attempted inside the retry window', () => {
expect(filterDueForProof([match], {[pendingTrade.id]: now - 1}, now).due).toEqual([]);
});

it('prunes attempts for sales that are no longer candidates', () => {
const {attempts} = filterDueForProof(
[match],
{[pendingTrade.id]: now - 1, [other.csfloatTrade.id]: now - 1},
now
);

expect(attempts).toEqual({[pendingTrade.id]: now - 1});
});
});
136 changes: 136 additions & 0 deletions src/lib/alarms/accepted_trade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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 {TradeStatus} from '../types/steam_constants';
import {reportTradeError} from './error_report';
import {isBackgroundNotaryAcceptedEnabled, proveTradesInBackground} from './notary';

interface AcceptedTradeInfo {
steamTrade: TradeHistoryStatus;
csfloatTrade: SlimTrade;
}

// The ping alarm runs every 3 minutes; if the server declines a sale, notary_accepted_at stays null,
// so without this we would re-prove the same sale every tick.
export const ACCEPTED_PROOF_RETRY_MS = 6 * 60 * 60 * 1000;

/**
* Pending sales whose most recent Steam trade is committed/complete inside trade protection and not yet proven.
* Keep in sync with phoenix: src/app/trades/accepted-proof.ts (selectAcceptedTradesToProve)
*/
export function findAcceptedTrades(
pendingTrades: SlimTrade[],
tradeHistory: TradeHistoryStatus[],
sellerSteamID: string
): AcceptedTradeInfo[] {
const results: AcceptedTradeInfo[] = [];

for (const csfloatTrade of pendingTrades) {
if (
csfloatTrade.state !== TradeState.PENDING ||
csfloatTrade.seller_id !== sellerSteamID ||
!csfloatTrade.accepted_at ||
csfloatTrade.notary_accepted_at
) {
continue;
}

const assetID = csfloatTrade.contract.item.asset_id;
const candidates = tradeHistory.filter(
(steamTrade) =>
!!steamTrade.time_init &&
steamTrade.other_party_id === csfloatTrade.buyer_id &&
steamTrade.given_assets.some((asset) => asset.asset_id === assetID)
);
if (candidates.length === 0) {
continue;
}

// Most recent attempt wins, same as the server, so a later rollback/failure blocks an older acceptance
const latest = candidates.reduce((a, b) => (b.time_init > a.time_init ? b : a));
const acceptedAtSec = Math.floor(new Date(csfloatTrade.accepted_at).getTime() / 1000);

if (
(latest.status !== TradeStatus.Committed && latest.status !== TradeStatus.Complete) ||
latest.rollback_trade ||
!latest.time_settlement ||
latest.time_init < acceptedAtSec
) {
continue;
}

results.push({steamTrade: latest, csfloatTrade});
}

return results;
}

type AttemptMap = Record<string, number>;

/**
* Drops sales attempted within the retry window and forgets sales that are no longer candidates.
*/
export function filterDueForProof(
acceptedTrades: AcceptedTradeInfo[],
attempts: AttemptMap,
now: number
): {due: AcceptedTradeInfo[]; attempts: AttemptMap} {
const due = acceptedTrades.filter(
(t) => !attempts[t.csfloatTrade.id] || attempts[t.csfloatTrade.id] <= now - ACCEPTED_PROOF_RETRY_MS
);
const pruned: AttemptMap = {};
for (const t of acceptedTrades) {
if (attempts[t.csfloatTrade.id]) {
pruned[t.csfloatTrade.id] = attempts[t.csfloatTrade.id];
}
}
return {due, attempts: pruned};
}

export async function pingAcceptedTrades(
pendingTrades: SlimTrade[],
tradeHistory: TradeHistoryStatus[],
sellerSteamID?: string | null
) {
if (!pendingTrades?.length || !tradeHistory?.length || !sellerSteamID) {
return;
}

const acceptedTrades = findAcceptedTrades(pendingTrades, tradeHistory, sellerSteamID);
if (acceptedTrades.length === 0 || !(await isBackgroundNotaryAcceptedEnabled())) {
return;
}

const now = Date.now();
const storedAttempts =
(await gStore.getWithStorage<AttemptMap>(chrome.storage.local, StorageKey.NOTARY_ACCEPTED_PROOF_ATTEMPTS)) ||
{};
const {due, attempts} = filterDueForProof(acceptedTrades, storedAttempts, now);
if (due.length === 0) {
return;
}

const lastFailure = await gStore.getWithStorage<number>(
chrome.storage.local,
StorageKey.LAST_NOTARY_BG_PROOF_FAILURE
);
if (lastFailure && lastFailure > now - 60 * 60 * 1000) {
console.log('skipping accepted-trade notary proof, last failure was less than 60 minutes ago');
return;
}

try {
// One proof spans the whole set, so include every candidate even if only some are due
await proveTradesInBackground(acceptedTrades.map((acceptedTrade) => acceptedTrade.steamTrade));
for (const t of acceptedTrades) {
attempts[t.csfloatTrade.id] = now;
}
await gStore.setWithStorage(chrome.storage.local, StorageKey.NOTARY_ACCEPTED_PROOF_ATTEMPTS, attempts);
console.log(`proved ${acceptedTrades.length} accepted trade(s) via notary`);
} catch (e) {
console.error('accepted-trade notary proving failed', e);
await gStore.setWithStorage(chrome.storage.local, StorageKey.LAST_NOTARY_BG_PROOF_FAILURE, now);
reportTradeError(due[0].csfloatTrade.id, `background extension accepted-trade notary failed: ${e}`);
}
}
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 @@ -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 {pingAcceptedTrades} from './accepted_trade';
import {FetchSlimTrades} from '../bridge/handlers/fetch_slim_trades';

export const PING_CSFLOAT_TRADE_STATUS_ALARM_NAME = 'ping_csfloat_trade_status_alarm';
Expand Down Expand Up @@ -75,6 +76,7 @@ interface UpdateErrors {
blocked_buyers_error?: string;
rollback_trades_error?: string;
failed_trades_error?: string;
accepted_trades_error?: string;
}

async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null): Promise<UpdateErrors> {
Expand Down Expand Up @@ -128,5 +130,12 @@ async function pingUpdates(pendingTrades: SlimTrade[], steamID?: string | null):
errors.failed_trades_error = (e as any).toString();
}

try {
await pingAcceptedTrades(pendingTrades, tradeHistory, steamID);
} catch (e) {
console.error('failed to prove accepted trades', e);
errors.accepted_trades_error = (e as any).toString();
}

return errors;
}
14 changes: 11 additions & 3 deletions src/lib/alarms/notary.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
export function isBackgroundNotaryRollbackEnabled(): Promise<boolean> {
return isBackgroundNotaryEnabled('rollback');
}

export function isBackgroundNotaryAcceptedEnabled(): Promise<boolean> {
return isBackgroundNotaryEnabled('accepted');
}

async function isBackgroundNotaryEnabled(setting: keyof NotaryMeta): Promise<boolean> {
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;
Expand Down
1 change: 1 addition & 0 deletions src/lib/storage/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_ACCEPTED_PROOF_ATTEMPTS = 'notary_accepted_proof_attempts', // csfloat trade id -> last 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
Expand Down
2 changes: 2 additions & 0 deletions src/lib/types/float_market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export interface Trade {
trade_url: string;
steam_offer: SteamOffer;
steam_trade_failed_id?: string;
// Set once a notarized Steam trade history proof showed this trade as accepted
notary_accepted_at?: string;
wait_for_cancel_ping?: boolean;
seller_blocked_buyer_at?: string;
buyer_blocked_seller_at?: string;
Expand Down