From afa4ed43ef9adc8560c6555faeb9a75801c18dbd Mon Sep 17 00:00:00 2001 From: Rob Konsdorf Date: Thu, 8 Oct 2026 15:34:45 -0400 Subject: [PATCH] refactor(filler): share one guarded update helper in the SimpleAssets asset processor Five actions of the SimpleAssets asset processor each built the same guarded UPDATE: set fields, match the asset, and require that the stored update block is not newer than the action. The transfer and the claim action also each wrote the transfer record with the same two inserts. One helper now builds the guarded update and one helper writes the transfer record. Each caller passes its own match text and values, so the generated SQL, the parameter order and the order of the database calls do not change. A new test covers the transfer record of a transfer action, which no test executed before. Signed-off-by: Rob Konsdorf --- .../processors/assets.integration.test.ts | 23 +++ .../simpleassets/processors/assets.ts | 134 +++++++++--------- 2 files changed, 88 insertions(+), 69 deletions(-) diff --git a/src/filler/handlers/simpleassets/processors/assets.integration.test.ts b/src/filler/handlers/simpleassets/processors/assets.integration.test.ts index f9f6d422..e5956597 100644 --- a/src/filler/handlers/simpleassets/processors/assets.integration.test.ts +++ b/src/filler/handlers/simpleassets/processors/assets.integration.test.ts @@ -408,6 +408,29 @@ describe('simpleassets assetProcessor', () => { }); }); + describe('transfer record', () => { + it('records the sender, the recipient, the memo cut to 256 characters and each asset when store_transfers is on', async () => { + register({ store_transfers: true }); + await seedAsset('600', { owner: 'alice' }); + await seedAsset('601', { owner: 'alice' }); + + await runBatch([{ + block: blockAt(6000), name: 'transfer', + data: { from: 'alice', to: 'bob', assetids: ['600', '601'], memo: 'm'.repeat(300) } + }]); + + const transfers = await client.query( + 'SELECT sender, recipient, memo FROM simpleassets_transfers WHERE contract = $1', [CONTRACT] + ); + expect(transfers.rows).to.deep.equal([{ sender: 'alice', recipient: 'bob', memo: 'm'.repeat(256) }]); + + const assets = await client.query( + 'SELECT asset_id, "index" FROM simpleassets_transfers_assets WHERE contract = $1 ORDER BY "index"', [CONTRACT] + ); + expect(assets.rows.map(row => [String(row.asset_id), Number(row.index)])).to.deep.equal([['600', 1], ['601', 2]]); + }); + }); + describe('catch-up write buffer', () => { it('gives consecutive ordinals across two create flushes in one transaction', async () => { await setTotal(GROUP_A, 10); diff --git a/src/filler/handlers/simpleassets/processors/assets.ts b/src/filler/handlers/simpleassets/processors/assets.ts index b816ab67..8f9a9bca 100644 --- a/src/filler/handlers/simpleassets/processors/assets.ts +++ b/src/filler/handlers/simpleassets/processors/assets.ts @@ -51,6 +51,11 @@ function compareCreates(a: PendingCreate, b: PendingCreate): number { return left < right ? -1 : (left > right ? 1 : 0); } +type AssetMatch = 'asset_id = $2' + | 'asset_id = ANY($2)' + | 'asset_id = ANY ($2)' + | 'asset_id = ANY ($2) AND owner = $3'; + export function assetProcessor(core: SimpleAssetsHandler, processor: DataProcessor): () => any { const destructors: Array<() => any> = []; const contract = core.args.simpleassets_account; @@ -63,6 +68,48 @@ export function assetProcessor(core: SimpleAssetsHandler, processor: DataProcess // create in the commit batch before any action that changes an asset. let pendingCreates: PendingCreate[] = []; + // The block guard keeps a replayed older action from overwriting a newer asset state. + // The match text starts after the contract condition ($1) and ends before the guard. Its + // parameters follow $1 in order, and the block number is the last parameter. The type lists + // each allowed text, so no caller can build one from action data. + async function updateAssetGuarded( + db: ContractDBTransaction, block: ShipBlock, fields: Record, match: AssetMatch, matchValues: any[] + ): Promise { + const blockTime = eosioTimestampToDate(block.timestamp).getTime(); + + await db.update('simpleassets_assets', { + ...fields, + updated_at_block: block.block_num, + updated_at_time: blockTime, + }, { + str: 'contract = $1 AND ' + match + ' AND updated_at_block <= $' + (matchValues.length + 2), + values: [contract, ...matchValues, block.block_num] + }, ['contract', 'asset_id']); + } + + async function insertTransferRecord( + db: ContractDBTransaction, block: ShipBlock, tx: EosioTransaction, trace: EosioActionTrace, + sender: string, recipient: string, memo: string, onConflict: 'update' | 'nothing' + ): Promise { + await db.insert('simpleassets_transfers', { + contract: contract, + transfer_id: trace.global_sequence, + sender, + recipient, + memo, + txid: Buffer.from(tx.id, 'hex'), + created_at_block: block.block_num, + created_at_time: eosioTimestampToDate(block.timestamp).getTime() + }, ['contract', 'transfer_id'], true, true, onConflict); + + await db.insert('simpleassets_transfers_assets', trace.act.data.assetids.map((assetID, index) => ({ + transfer_id: trace.global_sequence, + contract: contract, + index: index + 1, + asset_id: assetID + })), ['contract', 'transfer_id', 'asset_id'], true, true, 'update'); + } + async function readBaselineBlock(db: ContractDBTransaction): Promise { const query = await db.query( 'SELECT bootstrap_baseline_block FROM simpleassets_config WHERE contract = $1', @@ -215,80 +262,47 @@ export function assetProcessor(core: SimpleAssetsHandler, processor: DataProcess destructors.push(processor.onActionTrace( contract, 'burnlog', async (db: ContractDBTransaction, block: ShipBlock, tx: EosioTransaction, trace: EosioActionTrace): Promise => { - await db.update('simpleassets_assets', { + await updateAssetGuarded(db, block, { owner: null, burned_by_account: trace.act.data.owner, burned_at_block: block.block_num, burned_at_time: eosioTimestampToDate(block.timestamp).getTime(), - updated_at_block: block.block_num, - updated_at_time: eosioTimestampToDate(block.timestamp).getTime(), - }, { - str: 'contract = $1 AND asset_id = ANY($2) AND updated_at_block <= $3', - values: [contract, trace.act.data.assetids, block.block_num] - }, ['contract', 'asset_id']); + }, 'asset_id = ANY($2)', [trace.act.data.assetids]); }, SimpleAssetsUpdatePriority.ACTION_UPDATE_ASSET.valueOf() )); destructors.push(processor.onActionTrace( contract, 'update', async (db: ContractDBTransaction, block: ShipBlock, tx: EosioTransaction, trace: EosioActionTrace): Promise => { - await db.update('simpleassets_assets', { + await updateAssetGuarded(db, block, { mutable_data: encodeDatabaseJson(parseJsonObject(trace.act.data.mdata)), - updated_at_block: block.block_num, - updated_at_time: eosioTimestampToDate(block.timestamp).getTime(), - }, { - str: 'contract = $1 AND asset_id = $2 AND updated_at_block <= $3', - values: [contract, trace.act.data.assetid, block.block_num] - }, ['contract', 'asset_id']); + }, 'asset_id = $2', [trace.act.data.assetid]); }, SimpleAssetsUpdatePriority.ACTION_UPDATE_ASSET.valueOf() )); destructors.push(processor.onActionTrace( contract, 'changeauthor', async (db: ContractDBTransaction, block: ShipBlock, tx: EosioTransaction, trace: EosioActionTrace): Promise => { - await db.update('simpleassets_assets', { + await updateAssetGuarded(db, block, { author: trace.act.data.newauthor, - updated_at_block: block.block_num, - updated_at_time: eosioTimestampToDate(block.timestamp).getTime(), - }, { - str: 'contract = $1 AND asset_id = ANY($2) AND updated_at_block <= $3', - values: [contract, trace.act.data.assetids, block.block_num] - }, ['contract', 'asset_id']); + }, 'asset_id = ANY($2)', [trace.act.data.assetids]); }, SimpleAssetsUpdatePriority.ACTION_UPDATE_ASSET.valueOf() )); destructors.push(processor.onActionTrace( contract, 'transfer', async (db: ContractDBTransaction, block: ShipBlock, tx: EosioTransaction, trace: EosioActionTrace): Promise => { - await db.update('simpleassets_assets', { + await updateAssetGuarded(db, block, { owner: trace.act.data.to, transferred_at_block: block.block_num, transferred_at_time: eosioTimestampToDate(block.timestamp).getTime(), - updated_at_block: block.block_num, - updated_at_time: eosioTimestampToDate(block.timestamp).getTime(), - }, { - str: 'contract = $1 AND asset_id = ANY ($2) AND owner = $3 AND updated_at_block <= $4', - values: [contract, trace.act.data.assetids, trace.act.data.from, block.block_num] - }, ['contract', 'asset_id']); + }, 'asset_id = ANY ($2) AND owner = $3', [trace.act.data.assetids, trace.act.data.from]); if (core.args.store_transfers) { - await db.insert('simpleassets_transfers', { - contract: contract, - transfer_id: trace.global_sequence, - sender: trace.act.data.from, - recipient: trace.act.data.to, - memo: String(trace.act.data.memo).substr(0, 256), - txid: Buffer.from(tx.id, 'hex'), - created_at_block: block.block_num, - created_at_time: eosioTimestampToDate(block.timestamp).getTime() - }, ['contract', 'transfer_id'], true, true, 'update'); - - await db.insert('simpleassets_transfers_assets', trace.act.data.assetids.map((assetID, index) => ({ - transfer_id: trace.global_sequence, - contract: contract, - index: index + 1, - asset_id: assetID - })), ['contract', 'transfer_id', 'asset_id'], true, true, 'update'); + await insertTransferRecord( + db, block, tx, trace, + trace.act.data.from, trace.act.data.to, String(trace.act.data.memo).substr(0, 256), 'update' + ); } }, SimpleAssetsUpdatePriority.ACTION_UPDATE_ASSET.valueOf() )); @@ -303,36 +317,18 @@ export function assetProcessor(core: SimpleAssetsHandler, processor: DataProcess [contract, trace.act.data.assetids] ) : null; - await db.update('simpleassets_assets', { + await updateAssetGuarded(db, block, { owner: trace.act.data.claimer, transferred_at_block: block.block_num, transferred_at_time: eosioTimestampToDate(block.timestamp).getTime(), - updated_at_block: block.block_num, - updated_at_time: eosioTimestampToDate(block.timestamp).getTime(), - }, { - str: 'contract = $1 AND asset_id = ANY ($2) AND updated_at_block <= $3', - values: [contract, trace.act.data.assetids, block.block_num] - }, ['contract', 'asset_id']); + }, 'asset_id = ANY ($2)', [trace.act.data.assetids]); if (core.args.store_transfers) { // A replay reads the claimer as the owner, so the first write keeps the sender. - await db.insert('simpleassets_transfers', { - contract: contract, - transfer_id: trace.global_sequence, - sender: fromQuery.rowCount > 0 ? fromQuery.rows[0].owner : '.', - recipient: trace.act.data.claimer, - memo: '', - txid: Buffer.from(tx.id, 'hex'), - created_at_block: block.block_num, - created_at_time: eosioTimestampToDate(block.timestamp).getTime() - }, ['contract', 'transfer_id'], true, true, 'nothing'); - - await db.insert('simpleassets_transfers_assets', trace.act.data.assetids.map((assetID, index) => ({ - transfer_id: trace.global_sequence, - contract: contract, - index: index + 1, - asset_id: assetID - })), ['contract', 'transfer_id', 'asset_id'], true, true, 'update'); + await insertTransferRecord( + db, block, tx, trace, + fromQuery.rowCount > 0 ? fromQuery.rows[0].owner : '.', trace.act.data.claimer, '', 'nothing' + ); } }, SimpleAssetsUpdatePriority.ACTION_UPDATE_ASSET.valueOf() ));