From 3f7839b228ae87f390779db8c5a68d8e4c4586a0 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 10:33:37 -0600 Subject: [PATCH 1/3] feat: prove accepted trades via notary in the background ping loop - alarms/accepted_trade.ts: findAcceptedTrades (seller-side, mirrors nezha/phoenix predicate) + pingAcceptedTrades - gated on meta/notary accepted.background; per-trade 6h retry throttle since the alarm runs every 3 minutes - shared isBackgroundNotaryEnabled for rollback/accepted meta lookups - Trade.accepted_proof_verified_at; unit tests --- src/lib/alarms/accepted_trade.test.ts | 118 ++++++++++++++++++++++ src/lib/alarms/accepted_trade.ts | 136 ++++++++++++++++++++++++++ src/lib/alarms/csfloat_trade_pings.ts | 9 ++ src/lib/alarms/notary.ts | 14 ++- src/lib/storage/keys.ts | 1 + src/lib/types/float_market.ts | 2 + 6 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 src/lib/alarms/accepted_trade.test.ts create mode 100644 src/lib/alarms/accepted_trade.ts diff --git a/src/lib/alarms/accepted_trade.test.ts b/src/lib/alarms/accepted_trade.test.ts new file mode 100644 index 00000000..63143db6 --- /dev/null +++ b/src/lib/alarms/accepted_trade.test.ts @@ -0,0 +1,118 @@ +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 { + 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, accepted_proof_verified_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}); + }); +}); diff --git a/src/lib/alarms/accepted_trade.ts b/src/lib/alarms/accepted_trade.ts new file mode 100644 index 00000000..2cc0949e --- /dev/null +++ b/src/lib/alarms/accepted_trade.ts @@ -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, accepted_proof_verified_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.accepted_proof_verified_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; + +/** + * 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(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( + 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}`); + } +} diff --git a/src/lib/alarms/csfloat_trade_pings.ts b/src/lib/alarms/csfloat_trade_pings.ts index 872fba1b..63d9011e 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 {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'; @@ -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 { @@ -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; } diff --git a/src/lib/alarms/notary.ts b/src/lib/alarms/notary.ts index abbd1bb9..96d09957 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 isBackgroundNotaryAcceptedEnabled(): Promise { + return isBackgroundNotaryEnabled('accepted'); +} + +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; diff --git a/src/lib/storage/keys.ts b/src/lib/storage/keys.ts index 07c2386d..1566e980 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_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 diff --git a/src/lib/types/float_market.ts b/src/lib/types/float_market.ts index 334eb19e..6df60303 100644 --- a/src/lib/types/float_market.ts +++ b/src/lib/types/float_market.ts @@ -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 + accepted_proof_verified_at?: string; wait_for_cancel_ping?: boolean; seller_blocked_buyer_at?: string; buyer_blocked_seller_at?: string; From c95c9c061aa27ee7f8bc732b4df04bf47c292b6e Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 22:28:18 -0600 Subject: [PATCH 2/3] refactor: rename accepted_proof_verified_at to notary_accepted_at --- src/lib/alarms/accepted_trade.test.ts | 2 +- src/lib/alarms/accepted_trade.ts | 4 ++-- src/lib/types/float_market.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/alarms/accepted_trade.test.ts b/src/lib/alarms/accepted_trade.test.ts index 63143db6..be8ef95f 100644 --- a/src/lib/alarms/accepted_trade.test.ts +++ b/src/lib/alarms/accepted_trade.test.ts @@ -54,7 +54,7 @@ describe('findAcceptedTrades', () => { expect(findAcceptedTrades([{...pendingTrade, state: TradeState.VERIFIED}], history, sellerID)).toEqual([]); expect(findAcceptedTrades([{...pendingTrade, accepted_at: undefined}], history, sellerID)).toEqual([]); expect( - findAcceptedTrades([{...pendingTrade, accepted_proof_verified_at: acceptedAt}], history, sellerID) + findAcceptedTrades([{...pendingTrade, notary_accepted_at: acceptedAt}], history, sellerID) ).toEqual([]); expect(findAcceptedTrades([pendingTrade], history, buyerID)).toEqual([]); }); diff --git a/src/lib/alarms/accepted_trade.ts b/src/lib/alarms/accepted_trade.ts index 2cc0949e..81a1b5ef 100644 --- a/src/lib/alarms/accepted_trade.ts +++ b/src/lib/alarms/accepted_trade.ts @@ -11,7 +11,7 @@ interface AcceptedTradeInfo { csfloatTrade: SlimTrade; } -// The ping alarm runs every 3 minutes; if the server declines a sale, accepted_proof_verified_at stays null, +// 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; @@ -31,7 +31,7 @@ export function findAcceptedTrades( csfloatTrade.state !== TradeState.PENDING || csfloatTrade.seller_id !== sellerSteamID || !csfloatTrade.accepted_at || - csfloatTrade.accepted_proof_verified_at + csfloatTrade.notary_accepted_at ) { continue; } diff --git a/src/lib/types/float_market.ts b/src/lib/types/float_market.ts index 6df60303..e62dc9b0 100644 --- a/src/lib/types/float_market.ts +++ b/src/lib/types/float_market.ts @@ -106,7 +106,7 @@ export interface Trade { steam_offer: SteamOffer; steam_trade_failed_id?: string; // Set once a notarized Steam trade history proof showed this trade as accepted - accepted_proof_verified_at?: string; + notary_accepted_at?: string; wait_for_cancel_ping?: boolean; seller_blocked_buyer_at?: string; buyer_blocked_seller_at?: string; From eb9809b6613b34a98bbb230f1d2fe8deefc5a255 Mon Sep 17 00:00:00 2001 From: Step7750 Date: Thu, 3 Sep 2026 22:41:08 -0600 Subject: [PATCH 3/3] style: format accepted trade test --- src/lib/alarms/accepted_trade.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/alarms/accepted_trade.test.ts b/src/lib/alarms/accepted_trade.test.ts index be8ef95f..659a0974 100644 --- a/src/lib/alarms/accepted_trade.test.ts +++ b/src/lib/alarms/accepted_trade.test.ts @@ -53,9 +53,7 @@ describe('findAcceptedTrades', () => { 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, notary_accepted_at: acceptedAt}], history, sellerID)).toEqual([]); expect(findAcceptedTrades([pendingTrade], history, buyerID)).toEqual([]); });