From 83fb86223301be4b26fb137aecec18f85c6017a6 Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Tue, 13 Oct 2026 16:37:26 -0400 Subject: [PATCH] fix(filler): set the update block and time of an offer that turns invalid The filler turned an offer invalid with a write of the state column alone. Each other state write of the offer processor also sets updated_at_block and updated_at_time, so a reader of /v1/offers with a sort or a filter on the update time never saw an offer turn invalid. The transfer and burn trace handlers record the newest block with a moved asset, because the commit listener gets no block. The invalid write takes its block and time from that record. A commit of several blocks during catch-up uses the newest of them. The write that turns an offer pending again stays a write of the state alone. A new update time there makes a restored offer look like a new one to a reader that selects pending offers by update time. --- CHANGELOG.md | 14 ++ .../processors/offers.integration.test.ts | 131 ++++++++++++++++++ .../atomicassets/processors/offers.ts | 22 ++- 3 files changed, 164 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1531c07..014b0337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ order; the entry is the editorial text of the version's GitHub Release. The 1.7 maintenance line continues in `CHANGELOG.md` on the `release/1.7` branch. This project follows semantic versioning. +## [2.7.0] + +Moves the update time of a trade offer when the offer turns invalid. + +### Upgrading + +- Image `ghcr.io/atomicassets/atomicassets-api:2.7.0`. The `2.7` and `latest` tags move to it. +- The release has no migration and needs no operator action. + +### Bug fixes + +- `updated_at_time` and `updated_at_block` of an offer in `/v1/offers` now move to the block of the transfer or the burn of an offer asset when the offer turns invalid. Before, the offer turned invalid with no change to either value, so a reader that sorts or filters offers by update time never saw it. An offer that turns pending again after its assets return keeps the two values of the invalidation block. (#243) +- When the filler commits several blocks in one transaction during catch-up, the two values of an offer that turns invalid are those of the newest block of that transaction with an asset transfer or burn. (#243) + ## [2.6.1] Refuses an integer request parameter above the safe integer range with a 400 response. diff --git a/src/filler/handlers/atomicassets/processors/offers.integration.test.ts b/src/filler/handlers/atomicassets/processors/offers.integration.test.ts index f6477692..d984fdad 100644 --- a/src/filler/handlers/atomicassets/processors/offers.integration.test.ts +++ b/src/filler/handlers/atomicassets/processors/offers.integration.test.ts @@ -15,6 +15,8 @@ import { offerProcessor } from './offers'; import DataProcessor, { ProcessingState } from '../../../processor'; import { ContractDBTransaction } from '../../../database'; import { OfferState } from '../index'; +import { ShipBlock } from '../../../../types/ship'; +import { EosioActionTrace } from '../../../../types/eosio'; import { LogNewOfferActionData, AcceptOfferActionData, @@ -254,6 +256,135 @@ describe('offerProcessor', () => { }); }); + describe('invalidation timestamps', () => { + const SENDER = 'sender111111'; + const RECEIVER = 'receiver1111'; + + async function createOfferWithAsset(offerId: string, assetId: string): Promise<{ created: ShipBlock }> { + await client.query( + `INSERT INTO atomicassets_assets (contract, asset_id, collection_name, schema_name, owner, + transferred_at_block, transferred_at_time, updated_at_block, updated_at_time, minted_at_block, minted_at_time) + VALUES ($1, $2, 'testcol11111', 'testschema11', $3, 1, 1, 1, 1, 1, 1)`, + [CONTRACT, assetId, SENDER] + ); + const created = createBlock({ timestamp: '2023-01-01T00:00:00.000' }); + const data: LogNewOfferActionData = { + offer_id: offerId, + sender: SENDER, + recipient: RECEIVER, + sender_asset_ids: [assetId], + recipient_asset_ids: [], + memo: 'trade', + }; + await processActionTrace(processor, db, created, createTx(), createActionTrace(CONTRACT, 'lognewoffer', data)); + return { created }; + } + + function moveAsset(assetId: string, owner: string): Promise { + return client.query('UPDATE atomicassets_assets SET owner = $3 WHERE contract = $1 AND asset_id = $2', [CONTRACT, assetId, owner]); + } + + function transferTrace(assetId: string, from: string, to: string): EosioActionTrace { + return createActionTrace(CONTRACT, 'logtransfer', { + collection_name: 'testcol11111', + from, + to, + asset_ids: [assetId], + memo: '', + }); + } + + async function readOffer(offerId: string): Promise<{ state: number; block: number; time: number }> { + const result = await client.query( + 'SELECT state, updated_at_block, updated_at_time FROM atomicassets_offers WHERE contract = $1 AND offer_id = $2', + [CONTRACT, offerId] + ); + const row = result.rows[0]; + return { state: row.state, block: Number(row.updated_at_block), time: Number(row.updated_at_time) }; + } + + it('sets the update block and time of the newest transfer block when an offer turns invalid', async () => { + await createOfferWithAsset('300001', '5000001'); + await moveAsset('5000001', RECEIVER); + + // Two blocks in one commit batch: the newer block must win. + const older = createBlock({ timestamp: '2023-02-01T00:00:00.000' }); + const newer = createBlock({ timestamp: '2023-02-02T00:00:00.000' }); + processor.processActionTrace(older, createTx(), transferTrace('5000001', SENDER, RECEIVER)); + processor.processActionTrace(newer, createTx(), transferTrace('5000001', SENDER, RECEIVER)); + await processor.executeHeadQueue(db); + + expect(await readOffer('300001')).to.deep.equal({ + state: OfferState.INVALID.valueOf(), + block: newer.block_num, + time: new Date('2023-02-02T00:00:00.000Z').getTime(), + }); + }); + + it('sets the update block and time of the burn block when a burn turns an offer invalid', async () => { + await createOfferWithAsset('300002', '5000002'); + await client.query('UPDATE atomicassets_assets SET owner = NULL WHERE contract = $1 AND asset_id = $2', [CONTRACT, '5000002']); + + const burnBlock = createBlock({ timestamp: '2023-03-01T00:00:00.000' }); + const burn = createActionTrace(CONTRACT, 'logburnasset', { + asset_owner: SENDER, + asset_id: '5000002', + collection_name: 'testcol11111', + schema_name: 'testschema11', + template_id: -1, + backed_tokens: [], + old_immutable_data: [], + old_mutable_data: [], + asset_ram_payer: SENDER, + }); + await processActionTrace(processor, db, burnBlock, createTx(), burn); + + expect(await readOffer('300002')).to.deep.equal({ + state: OfferState.INVALID.valueOf(), + block: burnBlock.block_num, + time: new Date('2023-03-01T00:00:00.000Z').getTime(), + }); + }); + + it('keeps the invalidation block and time when the asset returns and the offer turns pending', async () => { + await createOfferWithAsset('300003', '5000003'); + await moveAsset('5000003', RECEIVER); + const invalidBlock = createBlock({ timestamp: '2023-04-01T00:00:00.000' }); + await processActionTrace(processor, db, invalidBlock, createTx(), transferTrace('5000003', SENDER, RECEIVER)); + + await moveAsset('5000003', SENDER); + const returnBlock = createBlock({ timestamp: '2023-04-05T00:00:00.000' }); + await processActionTrace(processor, db, returnBlock, createTx(), transferTrace('5000003', RECEIVER, SENDER)); + + expect(await readOffer('300003')).to.deep.equal({ + state: OfferState.PENDING.valueOf(), + block: invalidBlock.block_num, + time: new Date('2023-04-01T00:00:00.000Z').getTime(), + }); + }); + + it('leaves an accepted, declined, or cancelled offer unchanged when its asset moves', async () => { + const cases: Array<[string, string, string, string]> = [ + ['300004', '5000004', 'acceptoffer', 'ACCEPTED'], + ['300005', '5000005', 'declineoffer', 'DECLINED'], + ['300006', '5000006', 'canceloffer', 'CANCELLED'], + ]; + for (const [offerId, assetId, action, state] of cases) { + await createOfferWithAsset(offerId, assetId); + const closeBlock = createBlock({ timestamp: '2023-05-01T00:00:00.000' }); + await processActionTrace(processor, db, closeBlock, createTx(), createActionTrace(CONTRACT, action, { offer_id: offerId })); + await moveAsset(assetId, RECEIVER); + await processActionTrace(processor, db, createBlock({ timestamp: '2023-05-09T00:00:00.000' }), createTx(), transferTrace(assetId, SENDER, RECEIVER)); + + expect(await readOffer(offerId)).to.deep.equal({ + state: (OfferState as any)[state].valueOf(), + block: closeBlock.block_num, + time: new Date('2023-05-01T00:00:00.000Z').getTime(), + }); + } + }); + }); + describe('logtransfer chunking', () => { // The onCommit handler in offers.ts chunks `transferredAssets` at // CHUNK_SIZE=100 to bound planner variance on the offers_assets lookup. diff --git a/src/filler/handlers/atomicassets/processors/offers.ts b/src/filler/handlers/atomicassets/processors/offers.ts index 51328cf2..e80dddc2 100644 --- a/src/filler/handlers/atomicassets/processors/offers.ts +++ b/src/filler/handlers/atomicassets/processors/offers.ts @@ -97,10 +97,19 @@ export function offerProcessor(core: AtomicAssetsHandler, processor: DataProcess )); let transferredAssets: string[] = []; + // The commit listener gets no block, so the trace handlers record the + // newest block that moved an asset in the batch. + let newestMoveBlock: { num: number, time: number } | null = null; + const recordMoveBlock = (block: ShipBlock): void => { + if (newestMoveBlock === null || block.block_num > newestMoveBlock.num) { + newestMoveBlock = {num: block.block_num, time: eosioTimestampToDate(block.timestamp).getTime()}; + } + }; destructors.push(processor.onActionTrace( contract, 'logtransfer', async (db: ContractDBTransaction, block: ShipBlock, tx: EosioTransaction, trace: EosioActionTrace): Promise => { transferredAssets.push(...trace.act.data.asset_ids); + recordMoveBlock(block); }, AtomicAssetsUpdatePriority.INDEPENDENT.valueOf() )); @@ -108,6 +117,7 @@ export function offerProcessor(core: AtomicAssetsHandler, processor: DataProcess contract, 'logburnasset', async (db: ContractDBTransaction, block: ShipBlock, tx: EosioTransaction, trace: EosioActionTrace): Promise => { transferredAssets.push(trace.act.data.asset_id); + recordMoveBlock(block); }, AtomicAssetsUpdatePriority.INDEPENDENT.valueOf() )); @@ -208,9 +218,14 @@ export function offerProcessor(core: AtomicAssetsHandler, processor: DataProcess } if (invalidOffers.length > 0) { - await db.update('atomicassets_offers', { - state: OfferState.INVALID.valueOf() - }, { + // A restored offer (below) writes state only, so it keeps + // the block and time of this invalidation. + const invalidValues: Record = {state: OfferState.INVALID.valueOf()}; + if (newestMoveBlock !== null) { + invalidValues.updated_at_block = newestMoveBlock.num; + invalidValues.updated_at_time = newestMoveBlock.time; + } + await db.update('atomicassets_offers', invalidValues, { str: 'contract = $1 AND offer_id = ANY ($2) AND state = $3', values: [core.args.atomicassets_account, invalidOffers, OfferState.PENDING.valueOf()] }, ['contract', 'offer_id']); @@ -226,6 +241,7 @@ export function offerProcessor(core: AtomicAssetsHandler, processor: DataProcess } } finally { transferredAssets = []; + newestMoveBlock = null; } } ));