From d85568802255a7eb57740e2d02ccd80b09aa3aed Mon Sep 17 00:00:00 2001 From: F-OBrien Date: Tue, 25 Aug 2026 14:47:32 +0100 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=F0=9F=90=9B=20load=20SubQuery=20glo?= =?UTF-8?q?bals=20for=20scripts=20run=20through=20ts-node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ts-node ignores tsconfig `include` unless `files` is set, so the injected `api`/`store`/`logger` types never reached scripts. authorization-payloads.ts could not compile at all; eth-transact-senders.ts papered over it with a local `declare global`, which now conflicts and is removed. --- scripts/backfill/eth-transact-senders.ts | 13 ------------- tsconfig.json | 4 ++++ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/scripts/backfill/eth-transact-senders.ts b/scripts/backfill/eth-transact-senders.ts index e07a913d..dbd91ea9 100644 --- a/scripts/backfill/eth-transact-senders.ts +++ b/scripts/backfill/eth-transact-senders.ts @@ -36,19 +36,6 @@ * variables used by `db/utils.ts`; setting them inline keeps credentials out of argv. */ -/** - * `tsconfig.json` maps SubQuery's injected globals onto `src/**` only, so code running through - * ts-node declares what it uses itself. - */ -declare global { - const logger: { - debug: (message: string) => void; - error: (message: string) => void; - info: (message: string) => void; - warn: (message: string) => void; - }; -} - import { hexToU8a } from '@polkadot/util'; import { randomUUID } from 'node:crypto'; import { DataSource, EntityManager } from 'typeorm'; diff --git a/tsconfig.json b/tsconfig.json index da29a617..f2f4b76b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,10 @@ "rootDir": "src", "target": "es2017" }, + "ts-node": { + // load the `include` below, so scripts run through ts-node see SubQuery's injected globals + "files": true + }, "include": [ "src/**/*", "node_modules/@subql/types/dist/global.d.ts", From 465b38cbd4682829673c139dcb122ccc46343412 Mon Sep 17 00:00:00 2001 From: F-OBrien Date: Tue, 25 Aug 2026 14:54:35 +0100 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20share=20paginati?= =?UTF-8?q?on=20and=20dry-run=20helpers=20between=20backfills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves nextFetchSize, printDryRun and a new resumeFrom into historical.ts so both backfills use one implementation instead of each carrying its own. --- scripts/backfill/eth-transact-senders.ts | 24 +++++----------- scripts/backfill/historical.ts | 36 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/scripts/backfill/eth-transact-senders.ts b/scripts/backfill/eth-transact-senders.ts index dbd91ea9..8dcbf1bf 100644 --- a/scripts/backfill/eth-transact-senders.ts +++ b/scripts/backfill/eth-transact-senders.ts @@ -47,7 +47,13 @@ import { ethTxHash as computeEthTxHash, recoverEthSender, } from '../../src/utils/ethTransaction'; -import { CURRENT_REVISION, fetchCurrentBatch, updateCurrentRevisions } from './historical'; +import { + CURRENT_REVISION, + fetchCurrentBatch, + nextFetchSize, + printDryRun, + updateCurrentRevisions, +} from './historical'; // `src/utils/ethTransaction.ts` logs through the injected `logger`; nothing calls it before this (globalThis as any).logger = console; @@ -63,22 +69,6 @@ const UNATTRIBUTED_ETH_TRANSACT = */ const UNCLASSIFIED_ACCOUNTS = 'evm_address is null'; -/** Cap on the per-batch listing a dry run prints */ -const DRY_RUN_SAMPLE_SIZE = 20; - -/** Rows to request next, honouring `--limit` precisely instead of overshooting the batch boundary */ -const nextFetchSize = (args: Args, scanned: number): number => - args.limit === undefined ? args.batchSize : Math.min(args.batchSize, args.limit - scanned); - -/** Prints a capped sample of what a dry run would have written */ -const printDryRun = (rows: T[], describe: (row: T) => string): void => { - rows.slice(0, DRY_RUN_SAMPLE_SIZE).forEach(row => console.log(` ${describe(row)}`)); - - if (rows.length > DRY_RUN_SAMPLE_SIZE) { - console.log(` ...and ${rows.length - DRY_RUN_SAMPLE_SIZE} more in this batch`); - } -}; - /** Pinned rather than read from `registry.chainSS58` (as the forward path does) since this runs offline */ const DEFAULT_SS58_FORMAT = 12; diff --git a/scripts/backfill/historical.ts b/scripts/backfill/historical.ts index b16713a7..bb663e72 100644 --- a/scripts/backfill/historical.ts +++ b/scripts/backfill/historical.ts @@ -21,6 +21,42 @@ export type Queryable = Pick; /** Predicate selecting the row version visible at the current chain head. */ export const CURRENT_REVISION = 'upper(_block_range) is null'; +/** The paging options every backfill pass takes */ +export interface Paged { + batchSize: number; + limit?: number; +} + +/** Rows to request next, honouring `--limit` precisely instead of overshooting the batch boundary */ +export const nextFetchSize = ({ batchSize, limit }: Paged, scanned: number): number => + limit === undefined ? batchSize : Math.min(batchSize, limit - scanned); + +/** The `_id` to resume keyset pagination from, or `fallback` when the batch came back empty */ +export const resumeFrom = ( + rows: T[], + fallback: string | null +): string | null => { + let last = fallback; + + for (const { _id } of rows) { + last = _id; + } + + return last; +}; + +/** Cap on the per-batch listing a dry run prints */ +const DRY_RUN_SAMPLE_SIZE = 20; + +/** Prints a capped sample of what a dry run would have written */ +export const printDryRun = (rows: T[], describe: (row: T) => string): void => { + rows.slice(0, DRY_RUN_SAMPLE_SIZE).forEach(row => console.log(` ${describe(row)}`)); + + if (rows.length > DRY_RUN_SAMPLE_SIZE) { + console.log(` ...and ${rows.length - DRY_RUN_SAMPLE_SIZE} more in this batch`); + } +}; + export interface CurrentBatchOptions { /** Table to read from */ table: string; From 0f4f337db19bfcb42e0381e7dab9eafb07cbc6f9 Mon Sep 17 00:00:00 2001 From: F-OBrien Date: Tue, 25 Aug 2026 14:56:33 +0100 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20repair=20authorizati?= =?UTF-8?q?on=20payloads=20left=20naming=20a=20ticker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chain's v6 to v7 ticker_migrations rewrote TransferAssetOwnership and BecomeAgent payloads to asset ids without emitting an event, so rows indexed before that upgrade still name a ticker. The asset id is derivable from the ticker, so both the upgrade-time repair and the backfill run offline. --- scripts/backfill/authorization-payloads.ts | 227 ++++++++++++++++++ .../entities/block/mapChainUpgrade.ts | 2 + .../identities/repairAuthorizations.ts | 121 ++++++++++ tests/unit/authorizationRepair.test.ts | 211 ++++++++++++++++ 4 files changed, 561 insertions(+) create mode 100644 scripts/backfill/authorization-payloads.ts create mode 100644 src/mappings/entities/identities/repairAuthorizations.ts create mode 100644 tests/unit/authorizationRepair.test.ts diff --git a/scripts/backfill/authorization-payloads.ts b/scripts/backfill/authorization-payloads.ts new file mode 100644 index 00000000..c5f422dc --- /dev/null +++ b/scripts/backfill/authorization-payloads.ts @@ -0,0 +1,227 @@ +/** + * Backfills `authorizations.data` for rows indexed before the chain migrated stored authorization + * payloads from tickers to asset ids. + * + * The chain's v6 to v7 `ticker_migrations` rewrote each payload with `AssetId::from(ticker)`, which + * is `blake2_128(("legacy_ticker", ticker))` normalised to a v8 UUID. That is reproducible from the + * ticker already stored here, so this runs entirely against the database - no chain connection, and + * no dependence on an archive node still holding the state. + * + * Rows repaired are still-pending `TransferAssetOwnership`/`BecomeAgent` current revisions whose + * payload is a 12 byte ticker rather than a 16 byte asset id. Closed `_block_range` revisions are + * never touched, so the pre-repair payload stays queryable at its own block height. Pagination runs + * on `_id` because `id` is not unique under historical mode (see scripts/backfill/historical.ts). + * + * Usage (from the repo root): + * DB_HOST=h DB_PORT=p DB_USER=u DB_PASS=p DB_DATABASE=d \ + * yarn ts-node scripts/backfill/authorization-payloads.ts [--apply] + * [--batch-size=N] [--limit=N] + * + * Defaults to a dry run, which prints would-be updates and a summary without writing anything. + * Connection details come from the DB_* environment variables used by `db/utils.ts`; they can be + * set inline for one invocation, keeping credentials out of argv (and out of `ps`). + */ +import { DataSource } from 'typeorm'; + +import { getPostgresDataSource } from '../../db/utils'; +import { + AUTHORIZATION_TYPES_MIGRATED_TO_ASSET_IDS, + legacyTickerOf, + withAssetId, +} from '../../src/mappings/entities/identities/repairAuthorizations'; +import { getAssetIdForLegacyTicker } from '../../src/utils/assets'; +import { + fetchCurrentBatch, + nextFetchSize, + printDryRun, + resumeFrom, + updateCurrentRevisions, +} from './historical'; + +/** The `genesisBlock` row records the chain's genesis hash, which is what `chainId` holds */ +const GENESIS_BLOCK_ID = '0000000000'; + +/** Candidates: pending rows of the two types the migration rewrote */ +const REPAIRABLE_AUTHORIZATIONS = `status = 'Pending' and type in (${[ + ...AUTHORIZATION_TYPES_MIGRATED_TO_ASSET_IDS, +] + .map(type => `'${type}'`) + .join(', ')})`; + +interface Args { + apply: boolean; + batchSize: number; + limit?: number; +} + +const parseArgs = (): Args => { + const args: Args = { apply: false, batchSize: 500 }; + + for (const arg of process.argv.slice(2)) { + const [key, value] = arg.replace(/^--/, '').split('='); + + switch (key) { + case 'apply': + args.apply = true; + break; + case 'batch-size': + args.batchSize = Number(value); + break; + case 'limit': + args.limit = Number(value); + break; + default: + throw new Error(`Unknown argument "${arg}"`); + } + } + + return args; +}; + +interface PendingRow { + _id: string; + data: string | null; + id: string; +} + +interface PlannedRepair { + _id: string; + data: string; + id: string; + ticker: string; +} + +interface Summary { + repaired: number; + stale: number; +} + +/** + * `getAssetIdForLegacyTicker` reads the injected `chainId` to spot the one staging chain that + * migrated without the UUID normalisation. Outside the indexer that global does not exist, so it is + * taken from the genesis block row the genesis handler wrote it to. + */ +const loadChainId = async (postgres: DataSource): Promise => { + const [genesis] = await postgres.query('select hash from blocks where id = $1', [ + GENESIS_BLOCK_ID, + ]); + + if (!genesis?.hash) { + throw new Error( + `No genesis block row (id ${GENESIS_BLOCK_ID}) to read the chain id from. This instance was ` + + 'indexed without the genesis handler, so asset ids cannot be derived safely.' + ); + } + + (globalThis as any).chainId = genesis.hash; + console.log(`Deriving asset ids for chain ${genesis.hash}`); +}; + +const planRepairs = async (rows: PendingRow[], summary: Summary): Promise => { + const repairs: PlannedRepair[] = []; + + for (const row of rows) { + const ticker = legacyTickerOf(row.data); + + if (!ticker) { + continue; + } + + summary.stale += 1; + repairs.push({ + _id: row._id, + id: row.id, + ticker, + data: withAssetId(row.data, await getAssetIdForLegacyTicker(ticker)), + }); + } + + return repairs; +}; + +const applyRepairs = async ( + postgres: DataSource, + repairs: PlannedRepair[], + summary: Summary +): Promise => { + for (const { _id, data } of repairs) { + const updated = await updateCurrentRevisions( + postgres, + 'authorizations', + 'data = $1', + "_id = $2 and status = 'Pending'", + [data, _id] + ); + + if (updated) { + summary.repaired += 1; + } else { + logger.warn(`Authorization ${_id} was modified concurrently, skipping`); + } + } +}; + +const repairPass = async (postgres: DataSource, args: Args, summary: Summary): Promise => { + let afterId: string | null = null; + let scanned = 0; + + // keyset pagination on _id; stops on a short batch or once the limit is hit exactly + for (;;) { + const fetchSize = nextFetchSize(args, scanned); + + if (fetchSize <= 0) { + break; + } + + // annotated to keep strict-mode's circularity checker out of the pagination loop + const rows: PendingRow[] = await fetchCurrentBatch( + postgres, + { table: 'authorizations', where: REPAIRABLE_AUTHORIZATIONS }, + afterId, + fetchSize + ); + + afterId = resumeFrom(rows, afterId); + scanned += rows.length; + + const repairs = await planRepairs(rows, summary); + + if (args.apply) { + await applyRepairs(postgres, repairs, summary); + } else { + printDryRun(repairs, ({ id, ticker, data }) => `Would repair ${id}: ${ticker} -> ${data}`); + } + + if (rows.length < fetchSize) { + break; + } + } +}; + +const main = async (): Promise => { + const args = parseArgs(); + + const postgres = await getPostgresDataSource(); + const summary: Summary = { repaired: 0, stale: 0 }; + + try { + await loadChainId(postgres); + await repairPass(postgres, args, summary); + + console.log( + `Done. ${summary.stale} rows still named a ticker, ` + + `${ + args.apply ? `repaired ${summary.repaired}.` : 'nothing written. Pass --apply to write.' + }` + ); + } finally { + await postgres.destroy(); + } +}; + +main() + .then(() => process.exit(0)) + .catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/src/mappings/entities/block/mapChainUpgrade.ts b/src/mappings/entities/block/mapChainUpgrade.ts index 84b1080a..65f0c1df 100644 --- a/src/mappings/entities/block/mapChainUpgrade.ts +++ b/src/mappings/entities/block/mapChainUpgrade.ts @@ -1,5 +1,6 @@ import { SubstrateEvent } from '@subql/types'; import { handleMultiSigProposalDeleted } from '../multiSig/mapMultiSigProposal'; +import { repairAuthorizationsAfterUpgrade } from '../identities/repairAuthorizations'; let oldTxVersion = 0; let oldSpecVersion = 3000; @@ -53,6 +54,7 @@ export default async (substrateEvent: SubstrateEvent): Promise => { } else { logger.info(`Major chain upgrade found with transaction version upgraded `); await handleMultiSigProposalDeleted(substrateEvent.block); + await repairAuthorizationsAfterUpgrade(substrateEvent.block); oldTxVersion = txVersion; } diff --git a/src/mappings/entities/identities/repairAuthorizations.ts b/src/mappings/entities/identities/repairAuthorizations.ts new file mode 100644 index 00000000..cd3fc6d0 --- /dev/null +++ b/src/mappings/entities/identities/repairAuthorizations.ts @@ -0,0 +1,121 @@ +import { SubstrateBlock } from '@subql/types'; +import { Authorization, AuthorizationStatusEnum } from '../../../types'; +import { getAssetIdForLegacyTicker, getPaginatedData, is7xChain, padId } from '../../../utils'; + +/** + * Chain authorization types whose payloads the asset-id migration rewrote. + * + * `TransferTicker` names a ticker in every era and is deliberately excluded; every other variant + * carries no asset reference at all. + */ +export const AUTHORIZATION_TYPES_MIGRATED_TO_ASSET_IDS = new Set([ + 'TransferAssetOwnership', + 'BecomeAgent', +]); + +/** + * A `Ticker` is 12 bytes and an `AssetId` is 16, so within the types above the encoded length says + * which era a payload belongs to - no guessing at its contents. + */ +const LEGACY_TICKER_HEX = /^0x[0-9a-fA-F]{24}$/; + +/** + * The legacy ticker a payload names, or `undefined` when it already names an asset id. + * + * `TransferAssetOwnership` carries the value bare; `BecomeAgent` carries it at the head of a + * `[ticker, agentGroup]` pair. + */ +export const legacyTickerOf = (data?: string | null): string | undefined => { + if (!data) { + return undefined; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(data); + } catch { + return undefined; + } + + const value = Array.isArray(parsed) ? parsed[0] : parsed; + + return typeof value === 'string' && LEGACY_TICKER_HEX.test(value) ? value : undefined; +}; + +/** Rebuilds a payload with `assetId` in place of the ticker, leaving any other members alone */ +export const withAssetId = (data: string, assetId: string): string => { + const parsed = JSON.parse(data); + + return JSON.stringify(Array.isArray(parsed) ? [assetId, ...parsed.slice(1)] : assetId); +}; + +/** + * Whether a row still holds the ticker its creation event carried, and so needs the asset id the + * chain migrated to. + */ +export const needsAssetIdRepair = (row: { + status: AuthorizationStatusEnum; + type: string | null; + data?: string | null; +}): boolean => + AUTHORIZATION_TYPES_MIGRATED_TO_ASSET_IDS.has(row.type) && + row.status === AuthorizationStatusEnum.Pending && + legacyTickerOf(row.data) !== undefined; + +/** + * Repairs `authorizations.data` rows indexed before the chain migrated stored authorization + * payloads from tickers to asset ids. + * + * The chain's v6 to v7 `ticker_migrations` rewrote each payload with `AssetId::from(ticker)`, which + * is `blake2_128(("legacy_ticker", ticker))` normalised to a v8 UUID. That is reproducible from the + * ticker already stored here, so the repair derives the asset id locally rather than reading + * `identity.authorizations` back off the chain. + * + * Runs on transaction-version bumps of 7.x and later, and is idempotent - a repaired row no longer + * holds a 12 byte payload, so it is not a candidate on the next pass. + */ +export const repairAuthorizationsAfterUpgrade = async (block: SubstrateBlock): Promise => { + if (!is7xChain(block)) { + logger.info('Authorization payload repair skipped: chain predates asset-id payloads'); + + return; + } + + const blockId = padId(block.block.header.number.toString()); + + const pending = await getPaginatedData( + 'Authorization', + 'status', + AuthorizationStatusEnum.Pending + ); + + const stale = pending.filter(needsAssetIdRepair); + + logger.info(`Authorization payload repair found ${stale.length} rows still naming a ticker`); + + const repaired: Authorization[] = []; + let failed = 0; + + for (const row of stale) { + try { + const assetId = await getAssetIdForLegacyTicker(legacyTickerOf(row.data)); + + row.data = withAssetId(row.data, assetId); + row.updatedBlockId = blockId; + repaired.push(row); + } catch (e) { + failed += 1; + logger.warn(`Failed repairing authorization ${row.id} at block ${blockId}: ${e}`); + } + } + + if (repaired.length) { + await store.bulkUpdate('Authorization', repaired); + } + + logger.info( + `Authorization payload repair rewrote ${repaired.length} rows` + + (failed ? `, failed ${failed}` : '') + ); +}; diff --git a/tests/unit/authorizationRepair.test.ts b/tests/unit/authorizationRepair.test.ts new file mode 100644 index 00000000..8bebc762 --- /dev/null +++ b/tests/unit/authorizationRepair.test.ts @@ -0,0 +1,211 @@ +/** + * Unit tests for the authorization payload repair. + * + * The chain's v6 to v7 `ticker_migrations` rewrote `TransferAssetOwnership`/`BecomeAgent` payloads + * from tickers to asset ids without emitting an event, so rows indexed before that upgrade still + * name a ticker. The asset id is derivable from the ticker, so the repair needs no chain access. + */ + +import { SubstrateBlock } from '@subql/types'; +import { + legacyTickerOf, + needsAssetIdRepair, + repairAuthorizationsAfterUpgrade, + withAssetId, +} from '../../src/mappings/entities/identities/repairAuthorizations'; +import { Authorization, AuthorizationStatusEnum } from '../../src/types'; + +/** "T1" as it is actually stored on chain - trailing spaces, not NULs */ +const LEGACY_TICKER = '0x543120202020202020202020'; +/** `AssetId::from(Ticker)` for the above, as observed on live testnet for authorization 54631 */ +const ASSET_ID = '0x7edf86b7e651823cb21c2574e61c6ff3'; +const OTHER_ASSET_ID = '0x9d2c625f0a46803e9e982a644dab6fa7'; + +const globalAny = globalThis as any; + +const makeBlock = (specVersion = 7000005): SubstrateBlock => + ({ + specVersion, + block: { header: { number: { toString: () => '123456' } } }, + } as unknown as SubstrateBlock); + +const makeRow = (overrides: Partial = {}): Authorization => + Authorization.create({ + id: '54631', + type: 'TransferAssetOwnership' as Authorization['type'], + fromId: '0xfrom', + toId: '0xtarget', + status: AuthorizationStatusEnum.Pending, + createdBlockId: '0010000000', + updatedBlockId: '0010000000', + createdEventId: '0010000000/0000', + data: JSON.stringify(LEGACY_TICKER), + ...overrides, + }); + +describe('legacyTickerOf', () => { + it('reads a bare ticker payload (transferAssetOwnership)', () => { + expect(legacyTickerOf(JSON.stringify(LEGACY_TICKER))).toEqual(LEGACY_TICKER); + }); + + it('reads the ticker at the head of a pair (becomeAgent)', () => { + expect(legacyTickerOf(JSON.stringify([LEGACY_TICKER, { full: null }]))).toEqual(LEGACY_TICKER); + }); + + it('ignores a payload that already names an asset id', () => { + expect(legacyTickerOf(JSON.stringify(ASSET_ID))).toBeUndefined(); + expect(legacyTickerOf(JSON.stringify([ASSET_ID, { full: null }]))).toBeUndefined(); + }); + + it('ignores null, malformed and non-hex payloads', () => { + expect(legacyTickerOf(null)).toBeUndefined(); + expect(legacyTickerOf('not json')).toBeUndefined(); + expect(legacyTickerOf(JSON.stringify({ did: '0xabc' }))).toBeUndefined(); + expect(legacyTickerOf(JSON.stringify(42))).toBeUndefined(); + }); +}); + +describe('withAssetId', () => { + it('replaces a bare payload', () => { + expect(withAssetId(JSON.stringify(LEGACY_TICKER), ASSET_ID)).toEqual(JSON.stringify(ASSET_ID)); + }); + + it('replaces the head of a pair and keeps the agent group', () => { + expect(withAssetId(JSON.stringify([LEGACY_TICKER, { full: null }]), ASSET_ID)).toEqual( + JSON.stringify([ASSET_ID, { full: null }]) + ); + }); +}); + +describe('needsAssetIdRepair', () => { + const pending = AuthorizationStatusEnum.Pending; + + it('accepts a pending migrated-type row still naming a ticker', () => { + expect( + needsAssetIdRepair({ + status: pending, + type: 'TransferAssetOwnership', + data: JSON.stringify(LEGACY_TICKER), + }) + ).toBe(true); + expect( + needsAssetIdRepair({ + status: pending, + type: 'BecomeAgent', + data: JSON.stringify([LEGACY_TICKER, { full: null }]), + }) + ).toBe(true); + }); + + it('rejects a row already carrying an asset id, so re-runs are no-ops', () => { + expect( + needsAssetIdRepair({ + status: pending, + type: 'TransferAssetOwnership', + data: JSON.stringify(ASSET_ID), + }) + ).toBe(false); + }); + + it('rejects types the migration never rewrote', () => { + for (const type of ['TransferTicker', 'PortfolioCustody', 'JoinIdentity']) { + expect( + needsAssetIdRepair({ status: pending, type, data: JSON.stringify(LEGACY_TICKER) }) + ).toBe(false); + } + }); + + it('rejects rows that are no longer pending', () => { + for (const status of [ + AuthorizationStatusEnum.Consumed, + AuthorizationStatusEnum.Rejected, + AuthorizationStatusEnum.Revoked, + ]) { + expect( + needsAssetIdRepair({ + status, + type: 'TransferAssetOwnership', + data: JSON.stringify(LEGACY_TICKER), + }) + ).toBe(false); + } + }); +}); + +describe('repairAuthorizationsAfterUpgrade', () => { + beforeEach(() => { + globalAny.chainId = '0xnotstaging'; + jest.spyOn(globalAny.store, 'getByField').mockResolvedValue([]); + jest.spyOn(globalAny.store, 'bulkUpdate').mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const withRows = (rows: Authorization[]) => globalAny.store.getByField.mockResolvedValue(rows); + + it('derives the migrated asset id without reading chain state', async () => { + withRows([makeRow()]); + + await repairAuthorizationsAfterUpgrade(makeBlock()); + + const [entity, saved] = globalAny.store.bulkUpdate.mock.calls[0]; + expect(entity).toBe('Authorization'); + // the exact value the chain migration produced for this authorization + expect(saved[0].data).toBe(JSON.stringify(ASSET_ID)); + expect(saved[0].updatedBlockId).toBe('0000123456'); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('keeps the agent group when repairing a becomeAgent payload', async () => { + withRows([ + makeRow({ + type: 'BecomeAgent' as Authorization['type'], + data: JSON.stringify([LEGACY_TICKER, { full: null }]), + }), + ]); + + await repairAuthorizationsAfterUpgrade(makeBlock()); + + const [, saved] = globalAny.store.bulkUpdate.mock.calls[0]; + expect(saved[0].data).toBe(JSON.stringify([ASSET_ID, { full: null }])); + }); + + it('writes nothing when every row already carries an asset id (idempotent re-run)', async () => { + withRows([makeRow({ data: JSON.stringify(ASSET_ID) })]); + + await repairAuthorizationsAfterUpgrade(makeBlock()); + + expect(globalAny.store.bulkUpdate).not.toHaveBeenCalled(); + }); + + it('applies the staging chain exception, which migrated without UUID normalisation', async () => { + globalAny.chainId = '0x3c3183f6d701500766ff7d147b79c4f10014a095eaaa98e960dcef6b3ead50ee'; + withRows([makeRow()]); + + await repairAuthorizationsAfterUpgrade(makeBlock()); + + const [, saved] = globalAny.store.bulkUpdate.mock.calls[0]; + // same blake2 digest, without the version and variant bits forced + expect(saved[0].data).toBe(JSON.stringify('0x7edf86b7e651123cb21c2574e61c6ff3')); + }); + + it('does nothing on chains predating the asset-id migration', async () => { + // the v6 -> v7 `ticker_migrations` is what rewrote the payloads, so 6.x is still ticker-only + await repairAuthorizationsAfterUpgrade(makeBlock(6002000)); + + expect(globalAny.store.getByField).not.toHaveBeenCalled(); + expect(globalAny.store.bulkUpdate).not.toHaveBeenCalled(); + }); + + it('does not touch a row whose ticker differs, guarding the derivation', async () => { + withRows([makeRow({ data: JSON.stringify('0x543100000000000000000000') })]); + + await repairAuthorizationsAfterUpgrade(makeBlock()); + + const [, saved] = globalAny.store.bulkUpdate.mock.calls[0]; + // NUL padded "T1" is a different ticker, and derives to a different asset + expect(saved[0].data).toBe(JSON.stringify(OTHER_ASSET_ID)); + }); +});