From c2a088186689a26bb8ecf4e074d803b7ea8f63a0 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Wed, 12 Aug 2026 12:56:35 +0530 Subject: [PATCH 01/22] feat: enhance log fetching logic by introducing new error handling --- README.md | 3 +- .../obligation-registry-functions/fixtures.ts | 4 +- .../obligation-registry-functions/fixtures.ts | 2 +- .../lifecycle.test.ts | 2 +- .../rejectTransfers.test.ts | 4 +- .../returnToken.test.ts | 2 +- .../status.test.ts | 2 +- .../transfers.test.ts | 4 +- .../endorsement-chain/fetchEscrowTransfer.ts | 45 +++++++++---------- src/core/endorsement-chain/helpers.ts | 8 ++-- src/core/endorsement-chain/index.ts | 1 - src/core/endorsement-chain/obligation.ts | 33 -------------- .../endorsement-chain/useEndorsementChain.ts | 14 +----- src/obligation-registry-functions/utils.ts | 4 +- 14 files changed, 39 insertions(+), 89 deletions(-) delete mode 100644 src/core/endorsement-chain/obligation.ts diff --git a/README.md b/README.md index c084eb5..6a7e44f 100644 --- a/README.md +++ b/README.md @@ -905,10 +905,9 @@ Escrow calls accept `{ obligationRegistryAddress, tokenId }` or `{ obligationEsc **Endorsement chain** — pass the `TrustVCToken` address to existing helpers: ```ts -import { fetchEndorsementChain, fetchObligationEndorsementChain } from '@trustvc/trustvc'; +import { fetchEndorsementChain } from '@trustvc/trustvc'; const chain = await fetchEndorsementChain(obligationRegistry, tokenId, provider); -// or: fetchObligationEndorsementChain(obligationRegistry, tokenId, provider, { encryptionId }) ``` **Low-level contracts** (`@trustvc/trustvc/token-registry-v5/contracts`): diff --git a/src/__tests__/e2e/obligation-registry-functions/fixtures.ts b/src/__tests__/e2e/obligation-registry-functions/fixtures.ts index 8e00497..541ba02 100644 --- a/src/__tests__/e2e/obligation-registry-functions/fixtures.ts +++ b/src/__tests__/e2e/obligation-registry-functions/fixtures.ts @@ -6,7 +6,7 @@ import { mintObligationRegistry, } from '../../../obligation-registry-functions'; import type { TransactionOptions } from '../../../obligation-registry-functions/types'; -import { getObligationEscrowAddress } from '../../../core'; +import { getTitleEscrowAddress } from '../../../core'; import { getSignersV5, getSignersV6, providerV5, providerV6 } from '../fixtures'; import { createSampleBoeTxOptions } from '../fixtures/sample-boe-credential'; @@ -95,7 +95,7 @@ export const getObligationE2EEscrowAddress = async ( setup: ObligationE2ESetup, tokenId: string | number, ): Promise => { - return getObligationEscrowAddress(setup.obligationRegistry, String(tokenId), setup.provider, { + return getTitleEscrowAddress(setup.obligationRegistry, String(tokenId), setup.provider, { titleEscrowVersion: 'v5', }); }; diff --git a/src/__tests__/obligation-registry-functions/fixtures.ts b/src/__tests__/obligation-registry-functions/fixtures.ts index 8f446bb..8719fff 100644 --- a/src/__tests__/obligation-registry-functions/fixtures.ts +++ b/src/__tests__/obligation-registry-functions/fixtures.ts @@ -37,7 +37,7 @@ vi.mock('../../utils/ethers', async (importOriginal) => { vi.mock('../../core', () => ({ encrypt: vi.fn(() => 'encrypted_remarks'), - getObligationEscrowAddress: vi.fn(), + getTitleEscrowAddress: vi.fn(), checkSupportsInterface: vi.fn(), })); diff --git a/src/__tests__/obligation-registry-functions/lifecycle.test.ts b/src/__tests__/obligation-registry-functions/lifecycle.test.ts index 939c250..a164b4b 100644 --- a/src/__tests__/obligation-registry-functions/lifecycle.test.ts +++ b/src/__tests__/obligation-registry-functions/lifecycle.test.ts @@ -42,7 +42,7 @@ describe.each(providers)( } as unknown as Network); } - vi.spyOn(coreModule, 'getObligationEscrowAddress').mockResolvedValue( + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue( MOCK_OBLIGATION_ESCROW_ADDRESS, ); mockObligationEscrowContract.callStatic.accept.mockResolvedValue(true); diff --git a/src/__tests__/obligation-registry-functions/rejectTransfers.test.ts b/src/__tests__/obligation-registry-functions/rejectTransfers.test.ts index 3cd3cda..782856b 100644 --- a/src/__tests__/obligation-registry-functions/rejectTransfers.test.ts +++ b/src/__tests__/obligation-registry-functions/rejectTransfers.test.ts @@ -41,7 +41,7 @@ describe.each(providers)( } as unknown as Network); } - vi.spyOn(coreModule, 'getObligationEscrowAddress').mockResolvedValue( + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue( MOCK_OBLIGATION_ESCROW_ADDRESS, ); mockObligationEscrowContract.callStatic.rejectTransferHolder.mockResolvedValue(true); @@ -72,7 +72,7 @@ describe.each(providers)( ); expect(result).toEqual('reject_transfer_beneficiary_tx_hash'); - expect(coreModule.getObligationEscrowAddress).toHaveBeenCalled(); + expect(coreModule.getTitleEscrowAddress).toHaveBeenCalled(); }); it('rejectTransferOwnersObligationRegistry without remarks', async () => { diff --git a/src/__tests__/obligation-registry-functions/returnToken.test.ts b/src/__tests__/obligation-registry-functions/returnToken.test.ts index 1b8be4c..fdd1d10 100644 --- a/src/__tests__/obligation-registry-functions/returnToken.test.ts +++ b/src/__tests__/obligation-registry-functions/returnToken.test.ts @@ -43,7 +43,7 @@ describe.each(providers)( } as unknown as Network); } - vi.spyOn(coreModule, 'getObligationEscrowAddress').mockResolvedValue( + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue( MOCK_OBLIGATION_ESCROW_ADDRESS, ); vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(true); diff --git a/src/__tests__/obligation-registry-functions/status.test.ts b/src/__tests__/obligation-registry-functions/status.test.ts index 3f82b72..ae0767b 100644 --- a/src/__tests__/obligation-registry-functions/status.test.ts +++ b/src/__tests__/obligation-registry-functions/status.test.ts @@ -38,7 +38,7 @@ describe.each(providers)( vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ chainId: 1 } as unknown as Network); } - vi.spyOn(coreModule, 'getObligationEscrowAddress').mockResolvedValue( + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue( MOCK_OBLIGATION_ESCROW_ADDRESS, ); mockObligationEscrowContract.status.mockResolvedValue(ObligationDocumentStatus.Accepted); diff --git a/src/__tests__/obligation-registry-functions/transfers.test.ts b/src/__tests__/obligation-registry-functions/transfers.test.ts index 8183bbd..3ade9b5 100644 --- a/src/__tests__/obligation-registry-functions/transfers.test.ts +++ b/src/__tests__/obligation-registry-functions/transfers.test.ts @@ -43,7 +43,7 @@ describe.each(providers)( } as unknown as Network); } - vi.spyOn(coreModule, 'getObligationEscrowAddress').mockResolvedValue( + vi.spyOn(coreModule, 'getTitleEscrowAddress').mockResolvedValue( MOCK_OBLIGATION_ESCROW_ADDRESS, ); Object.values(mockObligationEscrowContract.callStatic).forEach((fn) => @@ -74,7 +74,7 @@ describe.each(providers)( ); expect(result).toEqual('transfer_holder_tx_hash'); - expect(coreModule.getObligationEscrowAddress).toHaveBeenCalled(); + expect(coreModule.getTitleEscrowAddress).toHaveBeenCalled(); }); it('transferBeneficiaryObligationRegistry without remarks', async () => { diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 0b58669..10d1d62 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -9,6 +9,7 @@ import { TitleEscrow as TitleEscrowV5, ObligationEscrow__factory, } from '../../token-registry-v5/contracts'; +import { supportInterfaceIds as supportInterfaceIdsV5 } from '../../token-registry-v5/supportInterfaceIds'; import { getEthersContractFromProvider } from '../../utils/ethers'; import { ParsedLog, @@ -45,41 +46,35 @@ export const fetchEscrowTransfersV5 = async ( titleEscrowAddress: string, tokenRegistryAddress?: string, ): Promise => { + const isObligationEscrow = await supportsObligationEscrow(titleEscrowAddress, provider); const Contract = getEthersContractFromProvider(provider); const titleEscrowContract = new Contract( titleEscrowAddress, - TitleEscrowFactoryV5.abi, + isObligationEscrow ? ObligationEscrow__factory.abi : TitleEscrowFactoryV5.abi, // eslint-disable-next-line @typescript-eslint/no-explicit-any provider as any, ); - return fetchAllTransfers(titleEscrowContract, titleEscrowAddress, tokenRegistryAddress); + return fetchAllTransfers( + titleEscrowContract, + titleEscrowAddress, + tokenRegistryAddress, + isObligationEscrow, + ); }; -/** - * ObligationEscrow shares Title Escrow V5 transfer events and adds status lifecycle events. - * @param {Provider | ethersV6.Provider} provider - Ethers provider - * @param {string} obligationEscrowAddress - ObligationEscrow contract address - * @param {string} [obligationRegistryAddress] - Obligation registry (TrustVCToken) address - * @returns {Promise} - Transfer and status events - */ -export const fetchEscrowTransfersObligation = async ( +const supportsObligationEscrow = async ( + contractAddress: string, provider: Provider | ethersV6.Provider, - obligationEscrowAddress: string, - obligationRegistryAddress?: string, -): Promise => { - const Contract = getEthersContractFromProvider(provider); - const obligationEscrowContract = new Contract( - obligationEscrowAddress, - ObligationEscrow__factory.abi, +): Promise => { + try { + const abi = ['function supportsInterface(bytes4 interfaceId) external view returns (bool)']; + const Contract = getEthersContractFromProvider(provider); // eslint-disable-next-line @typescript-eslint/no-explicit-any - provider as any, - ); - return fetchAllTransfers( - obligationEscrowContract, - obligationEscrowAddress, - obligationRegistryAddress, - true, - ); + const contract = new Contract(contractAddress, abi, provider as any); + return await contract.supportsInterface(supportInterfaceIdsV5.ObligationEscrow); + } catch { + return false; + } }; const getParsedLogs = ( diff --git a/src/core/endorsement-chain/helpers.ts b/src/core/endorsement-chain/helpers.ts index ee68312..c5601b2 100644 --- a/src/core/endorsement-chain/helpers.ts +++ b/src/core/endorsement-chain/helpers.ts @@ -87,14 +87,14 @@ const identifyEventTypeFromLogs = (groupedEvents: TransferBaseEvent[]): Transfer for (const event of groupedEvents) { if ( [ - 'STATUS_INITIALIZED', - 'STATUS_ACCEPTED', - 'STATUS_REJECTED', - 'STATUS_DISCHARGED', 'INITIAL', 'RETURNED_TO_ISSUER', 'RETURN_TO_ISSUER_ACCEPTED', 'RETURN_TO_ISSUER_REJECTED', + 'STATUS_INITIALIZED', + 'STATUS_ACCEPTED', + 'STATUS_REJECTED', + 'STATUS_DISCHARGED', ].includes(event.type) || event.type.startsWith('REJECT_') ) { diff --git a/src/core/endorsement-chain/index.ts b/src/core/endorsement-chain/index.ts index c362bb6..8c2457c 100644 --- a/src/core/endorsement-chain/index.ts +++ b/src/core/endorsement-chain/index.ts @@ -1,7 +1,6 @@ export * from './fetchEscrowTransfer'; export * from './fetchTokenTransfer'; export * from './helpers'; -export * from './obligation'; export * from './retrieveEndorsementChain'; export * from './types'; export * from './useEndorsementChain'; diff --git a/src/core/endorsement-chain/obligation.ts b/src/core/endorsement-chain/obligation.ts deleted file mode 100644 index 4cc378e..0000000 --- a/src/core/endorsement-chain/obligation.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Provider } from '@ethersproject/abstract-provider'; -import { ethers as ethersV6 } from 'ethersV6'; -import { EndorsementChain } from './types'; -import { fetchEndorsementChain, getTitleEscrowAddress } from './useEndorsementChain'; - -export const getObligationEscrowAddress = async ( - obligationRegistryAddress: string, - tokenId: string, - provider: Provider | ethersV6.Provider, - options?: { - titleEscrowVersion?: 'v4' | 'v5'; - }, -): Promise => { - return getTitleEscrowAddress(obligationRegistryAddress, tokenId, provider, options); -}; - -export const fetchObligationEndorsementChain = async ( - obligationRegistryAddress: string, - tokenId: string, - provider: Provider | ethersV6.Provider, - options?: { - encryptionId?: string; - obligationEscrowAddress?: string; - }, -): Promise => { - return fetchEndorsementChain( - obligationRegistryAddress, - tokenId, - provider, - options?.encryptionId, - options?.obligationEscrowAddress, - ); -}; diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 4ffde00..6f5bf7e 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -7,7 +7,6 @@ import { decrypt } from '../decrypt'; import { fetchEscrowTransfersV4, fetchEscrowTransfersV5, - fetchEscrowTransfersObligation, } from '../endorsement-chain/fetchEscrowTransfer'; import { fetchTokenTransfers } from '../endorsement-chain/fetchTokenTransfer'; import { mergeTransfersV4, mergeTransfersV5 } from '../endorsement-chain/helpers'; @@ -20,8 +19,6 @@ export const TitleEscrowInterface = { V5: supportInterfaceIdsV5.TitleEscrow, }; -export const ObligationEscrowInterface = supportInterfaceIdsV5.ObligationEscrow; - // Helper to fetch Title Escrow Factory Address const getTitleEscrowFactoryAddress = async ( tokenRegistryAddress: string, @@ -222,7 +219,7 @@ export const fetchEndorsementChain = async ( }), isTitleEscrowVersion({ titleEscrowAddress: resolvedTitleEscrowAddress, - versionInterface: ObligationEscrowInterface, + versionInterface: supportInterfaceIdsV5.ObligationEscrow, provider, }), ]); @@ -240,14 +237,7 @@ export const fetchEndorsementChain = async ( ]); transferEvents = mergeTransfersV4([...titleEscrowLogs, ...tokenLogs]); - } else if (isObligation) { - const obligationEscrowLogs = await fetchEscrowTransfersObligation( - provider, - resolvedTitleEscrowAddress, - tokenRegistryAddress, - ); - transferEvents = mergeTransfersV5(obligationEscrowLogs); - } else if (isV5) { + } else if (isV5 || isObligation) { const titleEscrowLogs = await fetchEscrowTransfersV5( provider, resolvedTitleEscrowAddress, diff --git a/src/obligation-registry-functions/utils.ts b/src/obligation-registry-functions/utils.ts index b7f2982..b564e1d 100644 --- a/src/obligation-registry-functions/utils.ts +++ b/src/obligation-registry-functions/utils.ts @@ -1,4 +1,4 @@ -import { encrypt, getObligationEscrowAddress, checkSupportsInterface } from '../core'; +import { encrypt, getTitleEscrowAddress, checkSupportsInterface } from '../core'; import { v5Contracts } from '../token-registry-v5'; import { getTxOptions } from '../token-registry-functions/utils'; import { Signer as SignerV6, Contract as ContractV6 } from 'ethersV6'; @@ -46,7 +46,7 @@ export const resolveObligationEscrowAddress = async ( throw new Error('Provider is required'); } - obligationEscrowAddress = await getObligationEscrowAddress( + obligationEscrowAddress = await getTitleEscrowAddress( obligationRegistryAddress, tokenId as string, signer.provider, From 61761829a2086b24fff0d7f5ef14d0b1b5c3128c Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Wed, 12 Aug 2026 13:56:29 +0530 Subject: [PATCH 02/22] feat: add constants for log fetching and enhance error handling in escrow transfer logic --- src/constants.ts | 19 + .../endorsement-chain/fetchEscrowTransfer.ts | 165 +++++-- .../endorsement-chain/fetchLogsChunked.ts | 434 ++++++++++++++++++ 3 files changed, 580 insertions(+), 38 deletions(-) create mode 100644 src/core/endorsement-chain/fetchLogsChunked.ts diff --git a/src/constants.ts b/src/constants.ts index 9108745..1fe0bf0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1,20 @@ export const DEFAULT_KEY = '4d5a4e3f2f6d2b0a1f2e9b8f8a3c7a0b8d4f5c2e7b1a1c3f2e7b8c2d5a4f7e3e'; + +export const INFURA_FREE_TIER_RANGE_RE = + /free tier plan|10\s*block difference|block range should work:\s*\[0x0,\s*0x9\]|Upgrade to PAYG|-32600/i; + +export const RANGE_TOO_LARGE_ERROR_RE = + /query returned more than|too large|block range|10,?000 results|response size|-32012|-32600|10\s*block|free tier|block difference|Upgrade to PAYG|exceeds limit/i; + +export const RATE_LIMIT_ERROR_RE = /429|rate-?limit|too many requests|could not coalesce|-32005/i; + +export const INITIAL_CHUNK_SIZE = 10_000; +export const FREE_TIER_MAX_CHUNK_SIZE = 10; +export const MIN_CHUNK_SIZE = 1; +export const MAX_CHUNK_SIZE = 50_000; +export const FREE_TIER_CONCURRENCY = 3; +export const DEFAULT_MAX_BLOCKS_TO_SCAN = 200_000; +export const FREE_TIER_MAX_REQUESTS = 5_000; +export const FREE_TIER_MAX_DURATION_MS = 60_000; +export const RATE_LIMIT_MAX_RETRIES = 3; +export const RATE_LIMIT_BASE_DELAY_MS = 500; diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 10d1d62..4204ed8 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -10,7 +10,9 @@ import { ObligationEscrow__factory, } from '../../token-registry-v5/contracts'; import { supportInterfaceIds as supportInterfaceIdsV5 } from '../../token-registry-v5/supportInterfaceIds'; +import { DEFAULT_MAX_BLOCKS_TO_SCAN } from '../../constants'; import { getEthersContractFromProvider } from '../../utils/ethers'; +import { isLogsRetryableError, scanLogsBackward } from './fetchLogsChunked'; import { ParsedLog, TitleEscrowTransferEvent, @@ -55,6 +57,7 @@ export const fetchEscrowTransfersV5 = async ( provider as any, ); return fetchAllTransfers( + provider, titleEscrowContract, titleEscrowAddress, tokenRegistryAddress, @@ -81,19 +84,19 @@ const getParsedLogs = ( logs: ethers.providers.Log[] | ethersV6.Log[], titleEscrow: TitleEscrowV4 | TitleEscrowV5, ): ParsedLog[] => { - return logs.map((log) => { + return logs.flatMap((log) => { if (!log.blockNumber) throw new Error('Block number not present'); - return { - ...log, + try { // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(titleEscrow.interface as any).parseLog(log), - }; + const parsed = (titleEscrow.interface as any).parseLog(log); + if (!parsed) return []; + return [{ ...log, ...parsed }]; + } catch { + return []; + } }); }; -/* - Retrieve all events that emits BENEFICIARY_TRANSFER -*/ const fetchOwnerTransfers = async ( titleEscrowContract: TitleEscrowV4, ): Promise => { @@ -110,9 +113,6 @@ const fetchOwnerTransfers = async ( })); }; -/* - Retrieve all events that emits HOLDER_TRANSFER -*/ const fetchHolderTransfers = async ( titleEscrowContract: TitleEscrowV4, ): Promise => { @@ -128,27 +128,45 @@ const fetchHolderTransfers = async ( })); }; -/** - * Retrieve all V5 / ObligationEscrow events - * @param {ethers.Contract | ethersV6.Contract} titleEscrowContract - Escrow contract - * @param {string} titleEscrowAddress - Escrow address - * @param {string} tokenRegistryAddress - Registry address - * @param {boolean} includeObligationStatus - When true, also collect ObligationEscrow status events - * @returns {Promise<(TitleEscrowTransferEvent | TokenTransferEvent)[]>} - Array of events - */ const fetchAllTransfers = async ( + provider: Provider | ethersV6.Provider, titleEscrowContract: ethers.Contract | ethersV6.Contract, titleEscrowAddress?: string, tokenRegistryAddress?: string, includeObligationStatus = false, ): Promise<(TitleEscrowTransferEvent | TokenTransferEvent)[]> => { + if (!titleEscrowAddress) { + titleEscrowAddress = titleEscrowContract?.address ?? (await titleEscrowContract.getAddress()); + } + + if (!tokenRegistryAddress) { + tokenRegistryAddress = await titleEscrowContract.registry(); + } + + const rawLogs = await fetchEscrowLogs( + provider, + titleEscrowContract, + titleEscrowAddress, + includeObligationStatus, + ); + const holderChangeLogsParsed = getParsedLogs( + rawLogs, + titleEscrowContract as unknown as TitleEscrowV5, + ); + + return mapParsedLogsToEvents(holderChangeLogsParsed, titleEscrowAddress, tokenRegistryAddress); +}; + +const buildEscrowFilters = ( + titleEscrowContract: ethers.Contract | ethersV6.Contract, + includeObligationStatus: boolean, // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allFilters: any[] = [ +): any[] => { + const filters = [ titleEscrowContract.filters.HolderTransfer, titleEscrowContract.filters.BeneficiaryTransfer, titleEscrowContract.filters.TokenReceived, titleEscrowContract.filters.ReturnToIssuer, - // titleEscrowContract.filters.Nomination, titleEscrowContract.filters.RejectTransferOwners, titleEscrowContract.filters.RejectTransferBeneficiary, titleEscrowContract.filters.RejectTransferHolder, @@ -156,34 +174,108 @@ const fetchAllTransfers = async ( ]; if (includeObligationStatus) { - allFilters.push( + filters.push( titleEscrowContract.filters.StatusInitialized, titleEscrowContract.filters.StatusAccepted, titleEscrowContract.filters.StatusRejected, titleEscrowContract.filters.StatusDischarged, ); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allLogs: any = await Promise.all( - allFilters.map(async (filter) => { - const logs = await titleEscrowContract.queryFilter(filter, 0, 'latest'); + + return filters; +}; + +const fetchLogsUnranged = async ( + titleEscrowContract: ethers.Contract | ethersV6.Contract, + includeObligationStatus: boolean, +): Promise => { + const allFilters = buildEscrowFilters(titleEscrowContract, includeObligationStatus); + const allLogs = await Promise.all( + allFilters.map(async (filterFactory) => { + const logs = await titleEscrowContract.queryFilter(filterFactory(), 0, 'latest'); return logs; }), ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return allLogs.flat() as any; +}; - const holderChangeLogsParsed = getParsedLogs( - allLogs.flat(), - titleEscrowContract as unknown as TitleEscrowV5, +const resolveEscrowScanFloor = async ( + titleEscrowContract: ethers.Contract | ethersV6.Contract, + latestBlock: number, +): Promise => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mintBlock = Number(await (titleEscrowContract as any).mintBlock()); + if (Number.isFinite(mintBlock) && mintBlock > 0 && mintBlock <= latestBlock) { + return mintBlock; + } + } catch { + // Title Escrow V5 does not expose mintBlock. + } + return 0; +}; + +const fetchLogsChunked = async ( + provider: Provider | ethersV6.Provider, + titleEscrowContract: ethers.Contract | ethersV6.Contract, + titleEscrowAddress: string, +): Promise => { + const latestBlock = await provider.getBlockNumber(); + const scanFloor = await resolveEscrowScanFloor(titleEscrowContract, latestBlock); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isMintLog = (log: any) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parsed = (titleEscrowContract.interface as any).parseLog(log); + return parsed?.name === 'TokenReceived' && parsed.args.isMinting; + } catch { + return false; + } + }; + + const result = await scanLogsBackward( + provider, + titleEscrowAddress, + latestBlock, + scanFloor, + isMintLog, + DEFAULT_MAX_BLOCKS_TO_SCAN, ); - if (!tokenRegistryAddress) { - tokenRegistryAddress = await titleEscrowContract.registry(); + if (!result.foundMint && result.truncated) { + throw new Error( + 'Unable to locate TokenReceived (mint) within the scan budget; refusing incomplete endorsement chain', + ); } - if (!titleEscrowAddress) { - // Handle ethers v5 and v6 differently - titleEscrowAddress = titleEscrowContract?.address ?? (await titleEscrowContract.getAddress()); + if (!result.foundMint) { + throw new Error( + 'Unable to locate TokenReceived (mint) before the escrow scan floor; refusing incomplete endorsement chain', + ); + } + + return result.logs; +}; + +const fetchEscrowLogs = async ( + provider: Provider | ethersV6.Provider, + titleEscrowContract: ethers.Contract | ethersV6.Contract, + titleEscrowAddress: string, + includeObligationStatus: boolean, +): Promise => { + try { + return await fetchLogsUnranged(titleEscrowContract, includeObligationStatus); + } catch (err) { + if (!isLogsRetryableError(err)) throw err; + return fetchLogsChunked(provider, titleEscrowContract, titleEscrowAddress); } +}; +const mapParsedLogsToEvents = ( + holderChangeLogsParsed: ParsedLog[], + titleEscrowAddress: string, + tokenRegistryAddress: string, +): (TitleEscrowTransferEvent | TokenTransferEvent)[] => { return holderChangeLogsParsed .map((event) => { if (event?.name === 'HolderTransfer') { @@ -205,7 +297,6 @@ const fetchAllTransfers = async ( remark: event.args?.remark, } as TitleEscrowTransferEvent; } else if (event?.name === 'TokenReceived') { - // MINT / RESTORE const type = identifyTokenReceivedType(event); return { type, @@ -223,7 +314,6 @@ const fetchAllTransfers = async ( return { type: 'RETURNED_TO_ISSUER', blockNumber: event.blockNumber, - // Handle ethers v5 and v6 differently from: titleEscrowAddress, to: tokenRegistryAddress, transactionHash: event.transactionHash, @@ -308,7 +398,6 @@ const fetchAllTransfers = async ( function identifyTokenReceivedType(event: ParsedLog): TokenTransferEventType { if (event.args.isMinting) { return 'INITIAL'; - } else { - return 'RETURN_TO_ISSUER_REJECTED'; } + return 'RETURN_TO_ISSUER_REJECTED'; } diff --git a/src/core/endorsement-chain/fetchLogsChunked.ts b/src/core/endorsement-chain/fetchLogsChunked.ts new file mode 100644 index 0000000..38bbc5e --- /dev/null +++ b/src/core/endorsement-chain/fetchLogsChunked.ts @@ -0,0 +1,434 @@ +import { ethers as ethersV6 } from 'ethersV6'; +import { Provider } from '@ethersproject/abstract-provider'; +import { + DEFAULT_MAX_BLOCKS_TO_SCAN, + FREE_TIER_CONCURRENCY, + FREE_TIER_MAX_CHUNK_SIZE, + FREE_TIER_MAX_DURATION_MS, + FREE_TIER_MAX_REQUESTS, + INFURA_FREE_TIER_RANGE_RE, + INITIAL_CHUNK_SIZE, + MAX_CHUNK_SIZE, + MIN_CHUNK_SIZE, + RANGE_TOO_LARGE_ERROR_RE, + RATE_LIMIT_BASE_DELAY_MS, + RATE_LIMIT_ERROR_RE, + RATE_LIMIT_MAX_RETRIES, +} from '../../constants'; + +function errorMessage(err: unknown): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyErr = err as any; + const parts = [ + anyErr?.message, + anyErr?.shortMessage, + anyErr?.error?.message, + anyErr?.info?.error?.message, + typeof anyErr?.statusCode === 'number' ? String(anyErr.statusCode) : undefined, + typeof anyErr?.code === 'number' || typeof anyErr?.code === 'string' + ? String(anyErr.code) + : undefined, + ].filter((part): part is string => typeof part === 'string' && part.length > 0); + + if (parts.length > 0) return parts.join(' '); + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message; + try { + return JSON.stringify(err); + } catch { + return 'Unknown error'; + } +} + +export function isLogsRetryableError(err: unknown): boolean { + const message = errorMessage(err); + return RATE_LIMIT_ERROR_RE.test(message) || RANGE_TOO_LARGE_ERROR_RE.test(message); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRateLimitError(err: unknown): boolean { + return RATE_LIMIT_ERROR_RE.test(errorMessage(err)); +} + +interface ScanLogsBackwardResult { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + logs: any[]; + foundMint: boolean; + truncated: boolean; +} + +interface AdaptiveScanState { + chunkSize: number; + maxChunkSize: number; +} + +interface FreeTierBudget { + requestsUsed: number; + deadlineAt: number; +} + +type BlockWindow = { start: number; end: number }; + +function shrinkForRangeLimit(state: AdaptiveScanState, message: string): void { + if (INFURA_FREE_TIER_RANGE_RE.test(message)) { + state.maxChunkSize = Math.min(state.maxChunkSize, FREE_TIER_MAX_CHUNK_SIZE); + } + state.chunkSize = Math.max(Math.floor(state.chunkSize / 4), MIN_CHUNK_SIZE); + state.chunkSize = Math.min(state.chunkSize, state.maxChunkSize); +} + +function assertBudgets(budget: FreeTierBudget, upcoming = 0): void { + if (Date.now() >= budget.deadlineAt) { + throw new Error(`RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`); + } + if (budget.requestsUsed + upcoming > FREE_TIER_MAX_REQUESTS) { + throw new Error( + `RPC scan request budget exhausted (${FREE_TIER_MAX_REQUESTS} eth_getLogs calls)`, + ); + } +} + +function withDeadline(promise: Promise, deadlineAt: number): Promise { + const remaining = deadlineAt - Date.now(); + if (remaining <= 0) { + return Promise.reject( + new Error(`RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`), + ); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`)); + }, remaining); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +async function getLogsRange( + provider: Provider | ethersV6.Provider, + address: string, + fromBlock: number, + toBlock: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + let attempt = 0; + while (true) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (await provider.getLogs({ address, fromBlock, toBlock })) as any[]; + } catch (err) { + if (isRateLimitError(err) && attempt < RATE_LIMIT_MAX_RETRIES) { + await sleep(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt); + attempt += 1; + continue; + } + throw err; + } + } +} + +async function getLogsRangeFreeTier( + provider: Provider | ethersV6.Provider, + address: string, + fromBlock: number, + toBlock: number, + budget: FreeTierBudget, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + for (let attempt = 0; ; attempt++) { + assertBudgets(budget); + budget.requestsUsed += 1; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pending = provider.getLogs({ address, fromBlock, toBlock }) as Promise; + return await withDeadline(pending, budget.deadlineAt); + } catch (err) { + if (isRateLimitError(err) && attempt < RATE_LIMIT_MAX_RETRIES) { + assertBudgets(budget); + await sleep( + Math.min(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt, budget.deadlineAt - Date.now()), + ); + continue; + } + throw err; + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function findMintSliceStart(logs: any[], isMintLog: (log: any) => boolean): number { + let mintIndex = -1; + for (let i = 0; i < logs.length; i++) { + if (isMintLog(logs[i])) { + mintIndex = i; + break; + } + } + if (mintIndex < 0) return -1; + + const txHash = logs[mintIndex].transactionHash; + let start = mintIndex; + while (start > 0 && logs[start - 1].transactionHash === txHash) { + start -= 1; + } + return start; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function flattenOldestFirst(chunkGroups: any[][]): any[] { + return chunkGroups.toReversed().flat(); +} + +function pushMintSliceIfFound( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chunkLogs: any[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMintLog: ((log: any) => boolean) | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + groups: any[][], +): boolean { + if (!isMintLog) return false; + const start = findMintSliceStart(chunkLogs, isMintLog); + if (start < 0) return false; + groups.push(chunkLogs.slice(start)); + return true; +} + +function buildParallelWindows( + cursor: number, + toBlockFloor: number, + windowSize: number, +): BlockWindow[] { + const windows: BlockWindow[] = []; + for (let winCursor = cursor, i = 0; i < FREE_TIER_CONCURRENCY && winCursor >= toBlockFloor; i++) { + const start = Math.max(winCursor - windowSize + 1, toBlockFloor); + windows.push({ start, end: winCursor }); + if (start <= toBlockFloor) break; + winCursor = start - 1; + } + return windows; +} + +function processSettledBatch( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + settled: PromiseSettledResult[], + windowSize: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): { results: any[][]; rangeTooLarge: boolean; hardError?: unknown } { + let rangeTooLarge = false; + let hardError: unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const results: any[][] = new Array(settled.length); + + for (let i = 0; i < settled.length; i++) { + const outcome = settled[i]; + if (outcome.status === 'fulfilled') { + results[i] = outcome.value; + continue; + } + const message = errorMessage(outcome.reason); + if (RANGE_TOO_LARGE_ERROR_RE.test(message) && windowSize > MIN_CHUNK_SIZE) { + rangeTooLarge = true; + } else if (!hardError) { + hardError = outcome.reason; + } + } + + return { results, rangeTooLarge, hardError }; +} + +function collectBatchChunks( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + results: any[][], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMintLog: ((log: any) => boolean) | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chunkGroups: any[][], +): boolean { + for (const chunkLogs of results) { + if (pushMintSliceIfFound(chunkLogs, isMintLog, chunkGroups)) { + return true; + } + chunkGroups.push(chunkLogs); + } + return false; +} + +const scanLogsBackwardParallel = async ( + provider: Provider | ethersV6.Provider, + address: string, + fromBlock: number, + toBlockFloor: number, + chunkSize: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMintLog?: (log: any) => boolean, +): Promise => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chunkGroups: any[][] = []; + let cursor = fromBlock; + let windowSize = Math.max(Math.min(chunkSize, FREE_TIER_MAX_CHUNK_SIZE), MIN_CHUNK_SIZE); + const budget: FreeTierBudget = { + requestsUsed: 0, + deadlineAt: Date.now() + FREE_TIER_MAX_DURATION_MS, + }; + + while (cursor >= toBlockFloor) { + const windows = buildParallelWindows(cursor, toBlockFloor, windowSize); + assertBudgets(budget, windows.length); + + const settled = await Promise.allSettled( + windows.map(({ start, end }) => getLogsRangeFreeTier(provider, address, start, end, budget)), + ); + const { results, rangeTooLarge, hardError } = processSettledBatch(settled, windowSize); + + if (hardError) throw hardError; + if (rangeTooLarge) { + windowSize = Math.max(Math.floor(windowSize / 4), MIN_CHUNK_SIZE); + continue; + } + + if (collectBatchChunks(results, isMintLog, chunkGroups)) { + return { logs: flattenOldestFirst(chunkGroups), foundMint: true, truncated: false }; + } + + const oldest = windows[windows.length - 1]; + if (oldest.start <= toBlockFloor) break; + cursor = oldest.start - 1; + } + + return { + logs: flattenOldestFirst(chunkGroups), + foundMint: false, + truncated: Boolean(isMintLog), + }; +}; + +async function handoffToFreeTier( + provider: Provider | ethersV6.Provider, + address: string, + cursor: number, + effectiveFloor: number, + chunkSize: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMintLog: ((log: any) => boolean) | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + newerChunkGroups: any[][], +): Promise { + const older = await scanLogsBackwardParallel( + provider, + address, + cursor, + effectiveFloor, + chunkSize, + isMintLog, + ); + return { + logs: [...older.logs, ...flattenOldestFirst(newerChunkGroups)], + foundMint: older.foundMint, + truncated: older.foundMint ? false : older.truncated, + }; +} + +async function fetchPaidTierChunk( + provider: Provider | ethersV6.Provider, + address: string, + cursor: number, + effectiveFloor: number, + state: AdaptiveScanState, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMintLog: ((log: any) => boolean) | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + newerChunkGroups: any[][], +): Promise<'mint' | 'continue' | 'done'> { + const chunkStart = Math.max(cursor - state.chunkSize + 1, effectiveFloor); + try { + const chunkLogs = await getLogsRange(provider, address, chunkStart, cursor); + if (pushMintSliceIfFound(chunkLogs, isMintLog, newerChunkGroups)) { + return 'mint'; + } + newerChunkGroups.push(chunkLogs); + } catch (err) { + const message = errorMessage(err); + if (RANGE_TOO_LARGE_ERROR_RE.test(message) && state.chunkSize > MIN_CHUNK_SIZE) { + shrinkForRangeLimit(state, message); + return 'continue'; + } + throw err; + } + return chunkStart <= effectiveFloor ? 'done' : 'continue'; +} + +export const scanLogsBackward = async ( + provider: Provider | ethersV6.Provider, + address: string, + fromBlock: number, + toBlockFloor: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMintLog?: (log: any) => boolean, + maxBlocksToScan: number = DEFAULT_MAX_BLOCKS_TO_SCAN, +): Promise => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newerChunkGroups: any[][] = []; + const state: AdaptiveScanState = { + chunkSize: Math.min(INITIAL_CHUNK_SIZE, MAX_CHUNK_SIZE), + maxChunkSize: MAX_CHUNK_SIZE, + }; + const budgetFloor = Math.max(0, fromBlock - maxBlocksToScan); + const effectiveFloor = Math.max(toBlockFloor, budgetFloor); + const budgetRaisedFloor = effectiveFloor > toBlockFloor; + let cursor = fromBlock; + + while (cursor >= effectiveFloor) { + if (state.maxChunkSize <= FREE_TIER_MAX_CHUNK_SIZE) { + return handoffToFreeTier( + provider, + address, + cursor, + effectiveFloor, + state.chunkSize, + isMintLog, + newerChunkGroups, + ); + } + + const priorChunkSize = state.chunkSize; + const outcome = await fetchPaidTierChunk( + provider, + address, + cursor, + effectiveFloor, + state, + isMintLog, + newerChunkGroups, + ); + if (outcome === 'mint') { + return { + logs: flattenOldestFirst(newerChunkGroups), + foundMint: true, + truncated: false, + }; + } + if (state.chunkSize !== priorChunkSize) continue; + + const chunkStart = Math.max(cursor - priorChunkSize + 1, effectiveFloor); + if (outcome === 'done' || chunkStart <= effectiveFloor) break; + cursor = chunkStart - 1; + } + + return { + logs: flattenOldestFirst(newerChunkGroups), + foundMint: false, + truncated: Boolean(isMintLog) && budgetRaisedFloor, + }; +}; From 24dbecb561f5217d3d13af2a6304d992fb258091 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Wed, 12 Aug 2026 14:44:45 +0530 Subject: [PATCH 03/22] feat: enhance error handling in log fetching and escrow transfer --- CLAUDE.md | 5 ++ README.md | 10 +++- .../endorsement-chain/fetchEscrowTransfer.ts | 19 +++++- .../endorsement-chain/fetchLogsChunked.ts | 59 ++++++++++++++++--- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e5cc40a..00b347e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,11 @@ in step. ## Gotchas (hard-won — add to this list) +- **Endorsement chain has one public path.** `fetchEndorsementChain` and + `fetchEscrowTransfersV5` auto-detect ObligationEscrow (status events included). + Do **not** re-add `fetchObligationEndorsementChain`, `fetchEscrowTransfersObligation`, + or `ObligationEscrowInterface`. Callers migrate to `fetchEndorsementChain`, + `fetchEscrowTransfersV5`, and `v5SupportInterfaceIds.ObligationEscrow`. - **Selective disclosure keeps the subject `id`.** If a credential was issued *with* a `credentialSubject.id`, deriving it (even revealing only other fields) **retains that id**. To test/produce a credential with *no* subject id, it must be issued without one. diff --git a/README.md b/README.md index 6a7e44f..8ad1a29 100644 --- a/README.md +++ b/README.md @@ -907,9 +907,17 @@ Escrow calls accept `{ obligationRegistryAddress, tokenId }` or `{ obligationEsc ```ts import { fetchEndorsementChain } from '@trustvc/trustvc'; -const chain = await fetchEndorsementChain(obligationRegistry, tokenId, provider); +const chain = await fetchEndorsementChain(obligationRegistry, tokenId, provider, encryptionKeyId); ``` +Obligation / BoE titles use the same functions as Token Registry V5 (`fetchEndorsementChain` auto-detects `ObligationEscrow`). These public aliases were removed: + +| Removed | Use instead | +| --- | --- | +| `fetchObligationEndorsementChain` | `fetchEndorsementChain` | +| `fetchEscrowTransfersObligation` | `fetchEscrowTransfersV5` (auto-detects obligation status events) | +| `ObligationEscrowInterface` | `v5SupportInterfaceIds.ObligationEscrow` | + **Low-level contracts** (`@trustvc/trustvc/token-registry-v5/contracts`): ```ts diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 4204ed8..1b1b450 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -65,6 +65,13 @@ export const fetchEscrowTransfersV5 = async ( ); }; +const isContractInterfaceCallException = (err: unknown): boolean => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const code = (err as any)?.code; + // CALL_EXCEPTION: contract revert / missing ERC-165. BAD_DATA: ethers v6 empty/undecodable return. + return code === 'CALL_EXCEPTION' || code === 'BAD_DATA'; +}; + const supportsObligationEscrow = async ( contractAddress: string, provider: Provider | ethersV6.Provider, @@ -75,8 +82,9 @@ const supportsObligationEscrow = async ( // eslint-disable-next-line @typescript-eslint/no-explicit-any const contract = new Contract(contractAddress, abi, provider as any); return await contract.supportsInterface(supportInterfaceIdsV5.ObligationEscrow); - } catch { - return false; + } catch (err) { + if (isContractInterfaceCallException(err)) return false; + throw err; } }; @@ -234,13 +242,18 @@ const fetchLogsChunked = async ( } }; + const maxBlocksToScan = + scanFloor > 0 + ? Math.max(DEFAULT_MAX_BLOCKS_TO_SCAN, latestBlock - scanFloor) + : DEFAULT_MAX_BLOCKS_TO_SCAN; + const result = await scanLogsBackward( provider, titleEscrowAddress, latestBlock, scanFloor, isMintLog, - DEFAULT_MAX_BLOCKS_TO_SCAN, + maxBlocksToScan, ); if (!result.foundMint && result.truncated) { diff --git a/src/core/endorsement-chain/fetchLogsChunked.ts b/src/core/endorsement-chain/fetchLogsChunked.ts index 38bbc5e..94ce39e 100644 --- a/src/core/endorsement-chain/fetchLogsChunked.ts +++ b/src/core/endorsement-chain/fetchLogsChunked.ts @@ -80,12 +80,28 @@ function shrinkForRangeLimit(state: AdaptiveScanState, message: string): void { state.chunkSize = Math.min(state.chunkSize, state.maxChunkSize); } +class BudgetExhaustedError extends Error { + constructor(message: string) { + super(message); + this.name = 'BudgetExhaustedError'; + } +} + +function isBudgetExhaustedError(err: unknown): boolean { + return ( + err instanceof BudgetExhaustedError || + (err instanceof Error && err.name === 'BudgetExhaustedError') + ); +} + function assertBudgets(budget: FreeTierBudget, upcoming = 0): void { if (Date.now() >= budget.deadlineAt) { - throw new Error(`RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`); + throw new BudgetExhaustedError( + `RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`, + ); } if (budget.requestsUsed + upcoming > FREE_TIER_MAX_REQUESTS) { - throw new Error( + throw new BudgetExhaustedError( `RPC scan request budget exhausted (${FREE_TIER_MAX_REQUESTS} eth_getLogs calls)`, ); } @@ -95,12 +111,18 @@ function withDeadline(promise: Promise, deadlineAt: number): Promise { const remaining = deadlineAt - Date.now(); if (remaining <= 0) { return Promise.reject( - new Error(`RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`), + new BudgetExhaustedError( + `RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`, + ), ); } return new Promise((resolve, reject) => { const timer = setTimeout(() => { - reject(new Error(`RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`)); + reject( + new BudgetExhaustedError( + `RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`, + ), + ); }, remaining); promise.then( (value) => { @@ -225,8 +247,9 @@ function processSettledBatch( settled: PromiseSettledResult[], windowSize: number, // eslint-disable-next-line @typescript-eslint/no-explicit-any -): { results: any[][]; rangeTooLarge: boolean; hardError?: unknown } { +): { results: any[][]; rangeTooLarge: boolean; budgetExhausted: boolean; hardError?: unknown } { let rangeTooLarge = false; + let budgetExhausted = false; let hardError: unknown; // eslint-disable-next-line @typescript-eslint/no-explicit-any const results: any[][] = new Array(settled.length); @@ -237,6 +260,10 @@ function processSettledBatch( results[i] = outcome.value; continue; } + if (isBudgetExhaustedError(outcome.reason)) { + budgetExhausted = true; + continue; + } const message = errorMessage(outcome.reason); if (RANGE_TOO_LARGE_ERROR_RE.test(message) && windowSize > MIN_CHUNK_SIZE) { rangeTooLarge = true; @@ -245,7 +272,7 @@ function processSettledBatch( } } - return { results, rangeTooLarge, hardError }; + return { results, rangeTooLarge, budgetExhausted, hardError }; } function collectBatchChunks( @@ -257,6 +284,7 @@ function collectBatchChunks( chunkGroups: any[][], ): boolean { for (const chunkLogs of results) { + if (!chunkLogs) continue; if (pushMintSliceIfFound(chunkLogs, isMintLog, chunkGroups)) { return true; } @@ -285,15 +313,25 @@ const scanLogsBackwardParallel = async ( while (cursor >= toBlockFloor) { const windows = buildParallelWindows(cursor, toBlockFloor, windowSize); - assertBudgets(budget, windows.length); + try { + assertBudgets(budget, windows.length); + } catch (err) { + if (isBudgetExhaustedError(err)) { + return { logs: flattenOldestFirst(chunkGroups), foundMint: false, truncated: true }; + } + throw err; + } const settled = await Promise.allSettled( windows.map(({ start, end }) => getLogsRangeFreeTier(provider, address, start, end, budget)), ); - const { results, rangeTooLarge, hardError } = processSettledBatch(settled, windowSize); + const { results, rangeTooLarge, budgetExhausted, hardError } = processSettledBatch( + settled, + windowSize, + ); if (hardError) throw hardError; - if (rangeTooLarge) { + if (rangeTooLarge && !budgetExhausted) { windowSize = Math.max(Math.floor(windowSize / 4), MIN_CHUNK_SIZE); continue; } @@ -301,6 +339,9 @@ const scanLogsBackwardParallel = async ( if (collectBatchChunks(results, isMintLog, chunkGroups)) { return { logs: flattenOldestFirst(chunkGroups), foundMint: true, truncated: false }; } + if (budgetExhausted) { + return { logs: flattenOldestFirst(chunkGroups), foundMint: false, truncated: true }; + } const oldest = windows[windows.length - 1]; if (oldest.start <= toBlockFloor) break; From c7cf4755e7d825b3373066fc72d21f75b686b5f1 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Wed, 12 Aug 2026 14:48:30 +0530 Subject: [PATCH 04/22] feat: add includeObligationStatus parameter to fetchEscrowTransfersV5 and update isObligation logic --- src/core/endorsement-chain/fetchEscrowTransfer.ts | 4 +++- src/core/endorsement-chain/useEndorsementChain.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 1b1b450..20fe9bb 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -47,8 +47,10 @@ export const fetchEscrowTransfersV5 = async ( provider: Provider | ethersV6.Provider, titleEscrowAddress: string, tokenRegistryAddress?: string, + includeObligationStatus?: boolean, ): Promise => { - const isObligationEscrow = await supportsObligationEscrow(titleEscrowAddress, provider); + const isObligationEscrow = + includeObligationStatus ?? (await supportsObligationEscrow(titleEscrowAddress, provider)); const Contract = getEthersContractFromProvider(provider); const titleEscrowContract = new Contract( titleEscrowAddress, diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 6f5bf7e..babb4e7 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -242,6 +242,7 @@ export const fetchEndorsementChain = async ( provider, resolvedTitleEscrowAddress, tokenRegistryAddress, + isObligation, ); transferEvents = mergeTransfersV5(titleEscrowLogs); } From faeee4405b8560d4a943b3e2012b3972122bd834 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Wed, 12 Aug 2026 15:44:29 +0530 Subject: [PATCH 05/22] feat: add fetchEndorsementChain function for unified escrow path handling --- CLAUDE.md | 1 + src/core/endorsement-chain/useEndorsementChain.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 00b347e..c6f1ba1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ Source map (`src/`): | Path | What | | --- | --- | | `src/core/verify.ts` | **`verifyDocument()`** — the unified verify entry point (OA + W3C). | +| `src/core/endorsement-chain/useEndorsementChain.ts` | **`fetchEndorsementChain()`** — unified V4/V5/Obligation escrow path (auto-detects contract type). | | `src/verify/verify.ts` | `verificationBuilder`, `openAttestationVerifiers`, `w3cVerifiers`. | | `src/verify/fragments/` | Verifier fragments by dimension: `document-integrity`, `document-status`, `issuer-identity`, `presentation`. | | `src/w3c/` | The W3C surface: `sign`, `derive`, `verify`, **`presentation`** (VP wrappers), `types`. | diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index babb4e7..32735cb 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -206,6 +206,11 @@ export const fetchEndorsementChain = async ( const resolvedTitleEscrowAddress = titleEscrowAddress ?? (await getTitleEscrowAddress(tokenRegistryAddress, tokenId, provider)); + // Migration: obligation/BoE titles are handled here via supportsInterface detection. + // Removed public aliases — use fetchEndorsementChain (this function), + // fetchEscrowTransfersV5, and supportInterfaceIdsV5.ObligationEscrow instead of + // fetchObligationEndorsementChain, fetchEscrowTransfersObligation, or + // ObligationEscrowInterface. const [isV4, isV5, isObligation] = await Promise.all([ isTitleEscrowVersion({ titleEscrowAddress: resolvedTitleEscrowAddress, From acbd3fe8b178c2ef3a4b4f3221e1774d133bbbf4 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Wed, 12 Aug 2026 15:55:12 +0530 Subject: [PATCH 06/22] Update src/core/endorsement-chain/fetchEscrowTransfer.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/core/endorsement-chain/fetchEscrowTransfer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 20fe9bb..79c1eb7 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -68,8 +68,7 @@ export const fetchEscrowTransfersV5 = async ( }; const isContractInterfaceCallException = (err: unknown): boolean => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const code = (err as any)?.code; + const code = (err as { code?: unknown } | null | undefined)?.code; // CALL_EXCEPTION: contract revert / missing ERC-165. BAD_DATA: ethers v6 empty/undecodable return. return code === 'CALL_EXCEPTION' || code === 'BAD_DATA'; }; From 53e346cf0e40ddab435c1be0f268137681b6d745 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Wed, 12 Aug 2026 15:57:23 +0530 Subject: [PATCH 07/22] refactor: streamline endorsement chain handling by consolidating public aliases --- CLAUDE.md | 26 +++++++++++++++---- .../endorsement-chain/useEndorsementChain.ts | 22 ++++++++++++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c6f1ba1..c584b74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,13 +104,29 @@ so an unsigned VP is still routed in and then judged INVALID by the integrity fr aligned to both enforce proof-presence + holder binding. If you touch one, keep the other in step. +## Endorsement chain (`src/core/endorsement-chain/useEndorsementChain.ts`) + +**`fetchEndorsementChain()`** is the single public path for Token Registry V4/V5 and +Obligation/BoE titles. It auto-detects the escrow contract via `supportsInterface` +(including `supportInterfaceIdsV5.ObligationEscrow` around the obligation check). + +These public aliases were removed: + +| Removed | Use instead | +| --- | --- | +| `fetchObligationEndorsementChain` | `fetchEndorsementChain` | +| `fetchEscrowTransfersObligation` | `fetchEscrowTransfersV5` (auto-detects obligation status events) | +| `ObligationEscrowInterface` | `v5SupportInterfaceIds.ObligationEscrow` | + +Do **not** re-add the removed aliases. User-facing docs also live in `README.md` +(Obligation Registry section). + ## Gotchas (hard-won — add to this list) -- **Endorsement chain has one public path.** `fetchEndorsementChain` and - `fetchEscrowTransfersV5` auto-detect ObligationEscrow (status events included). - Do **not** re-add `fetchObligationEndorsementChain`, `fetchEscrowTransfersObligation`, - or `ObligationEscrowInterface`. Callers migrate to `fetchEndorsementChain`, - `fetchEscrowTransfersV5`, and `v5SupportInterfaceIds.ObligationEscrow`. +- **Endorsement chain has one public path.** See + [Endorsement chain](#endorsement-chain-srccoreendorsement-chainuseendorsementchaints) + — do not re-add `fetchObligationEndorsementChain`, `fetchEscrowTransfersObligation`, + or `ObligationEscrowInterface`. - **Selective disclosure keeps the subject `id`.** If a credential was issued *with* a `credentialSubject.id`, deriving it (even revealing only other fields) **retains that id**. To test/produce a credential with *no* subject id, it must be issued without one. diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 32735cb..93b5046 100644 --- a/src/core/endorsement-chain/useEndorsementChain.ts +++ b/src/core/endorsement-chain/useEndorsementChain.ts @@ -193,6 +193,22 @@ export const isTitleEscrowVersion = async ({ } }; +/** + * Fetch the endorsement chain for Token Registry V4/V5 or Obligation/BoE titles. + * + * Auto-detects Title Escrow V4/V5 and ObligationEscrow via supportsInterface. + * + * Migration — these public aliases were removed; use this function instead: + * - fetchObligationEndorsementChain → fetchEndorsementChain + * - fetchEscrowTransfersObligation → fetchEscrowTransfersV5 + * - ObligationEscrowInterface → supportInterfaceIdsV5.ObligationEscrow + * @param {string} tokenRegistryAddress - Token registry or obligation registry address + * @param {string} tokenId - Token ID + * @param {Provider | ethersV6.Provider} provider - Ethers provider + * @param {string} [keyId] - Encryption key ID for decrypting remarks (V5/Obligation) + * @param {string} [titleEscrowAddress] - Pre-resolved escrow address (optional) + * @returns {Promise} Endorsement chain events + */ export const fetchEndorsementChain = async ( tokenRegistryAddress: string, tokenId: string, @@ -206,11 +222,6 @@ export const fetchEndorsementChain = async ( const resolvedTitleEscrowAddress = titleEscrowAddress ?? (await getTitleEscrowAddress(tokenRegistryAddress, tokenId, provider)); - // Migration: obligation/BoE titles are handled here via supportsInterface detection. - // Removed public aliases — use fetchEndorsementChain (this function), - // fetchEscrowTransfersV5, and supportInterfaceIdsV5.ObligationEscrow instead of - // fetchObligationEndorsementChain, fetchEscrowTransfersObligation, or - // ObligationEscrowInterface. const [isV4, isV5, isObligation] = await Promise.all([ isTitleEscrowVersion({ titleEscrowAddress: resolvedTitleEscrowAddress, @@ -222,6 +233,7 @@ export const fetchEndorsementChain = async ( versionInterface: TitleEscrowInterface.V5, provider, }), + // ObligationEscrow detection (replaces removed ObligationEscrowInterface alias). isTitleEscrowVersion({ titleEscrowAddress: resolvedTitleEscrowAddress, versionInterface: supportInterfaceIdsV5.ObligationEscrow, From 0411334abd8f1b058a4a6c7488a852b62ddf69fa Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 11:05:30 +0530 Subject: [PATCH 08/22] feat: enhance obligation handling in endorsement chain by integrating termination --- CLAUDE.md | 11 +++++ .../endorsement-chain/fetchEscrowTransfer.ts | 32 +++++++++++++++ src/core/endorsement-chain/helpers.ts | 41 ++++++++++++++++--- .../retrieveEndorsementChain.ts | 30 ++++++++++---- src/core/endorsement-chain/types.ts | 5 +++ 5 files changed, 107 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c584b74..3c413e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,17 @@ Do **not** re-add the removed aliases. User-facing docs also live in `README.md` [Endorsement chain](#endorsement-chain-srccoreendorsement-chainuseendorsementchaints) — do not re-add `fetchObligationEndorsementChain`, `fetchEscrowTransfersObligation`, or `ObligationEscrowInterface`. +- **Obligation mint merges to INITIAL.** ObligationEscrow emits `StatusInitialized` + in the same tx as `TokenReceived(isMinting)` (often *before* it in log order). + `mergeTransfersV5` must prefer `INITIAL` so owner/holder/remarks match classic ETR + mint rows. Do not let `STATUS_INITIALIZED` win that merge. +- **eBoE shred last parties + reason come from the contract.** ObligationEscrow + persists `lastBeneficiary` / `lastHolder` in `_deactivate` and emits them on + `Shred` with `TerminationReason`. SDK maps those onto `RETURN_TO_ISSUER_ACCEPTED` + (`owner`/`holder`/`terminationReason`). Do not reconstruct parties from transfer + history as the primary source of truth. Classic ETR shred UI still blanks parties + (no reason field). **ABI break:** redeploy or upgrade obligation registries / + escrow impl before validating against live docs. - **Selective disclosure keeps the subject `id`.** If a credential was issued *with* a `credentialSubject.id`, deriving it (even revealing only other fields) **retains that id**. To test/produce a credential with *no* subject id, it must be issued without one. diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 79c1eb7..04c50ae 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -15,6 +15,7 @@ import { getEthersContractFromProvider } from '../../utils/ethers'; import { isLogsRetryableError, scanLogsBackward } from './fetchLogsChunked'; import { ParsedLog, + TerminationReasonLabel, TitleEscrowTransferEvent, TokenTransferEvent, TokenTransferEventType, @@ -22,6 +23,21 @@ import { } from '../endorsement-chain/types'; import { Provider } from '@ethersproject/abstract-provider'; +const TERMINATION_REASON_LABELS: TerminationReasonLabel[] = [ + 'None', + 'ReturnToIssuer', + 'Rejected', + 'Discharged', +]; + +const toTerminationReasonLabel = (reason: unknown): TerminationReasonLabel | undefined => { + const index = Number(reason); + if (!Number.isInteger(index) || index < 0 || index >= TERMINATION_REASON_LABELS.length) { + return undefined; + } + return TERMINATION_REASON_LABELS[index]; +}; + export const fetchEscrowTransfersV4 = async ( provider: Provider | ethersV6.Provider, address: string, @@ -319,6 +335,9 @@ const mapParsedLogsToEvents = ( ? '0x0000000000000000000000000000000000000000' : tokenRegistryAddress, to: titleEscrowAddress, + // TokenReceived carries beneficiary/holder — needed when merge prefers INITIAL. + owner: event.args?.beneficiary, + holder: event.args?.holder, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, @@ -339,6 +358,8 @@ const mapParsedLogsToEvents = ( } else if (event?.name === 'RejectTransferOwners') { return { type: 'REJECT_TRANSFER_OWNERS', + owner: event.args?.toBeneficiary, + holder: event.args?.toHolder, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, @@ -347,6 +368,7 @@ const mapParsedLogsToEvents = ( } else if (event?.name === 'RejectTransferBeneficiary') { return { type: 'REJECT_TRANSFER_BENEFICIARY', + owner: event.args?.toBeneficiary, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, @@ -355,20 +377,27 @@ const mapParsedLogsToEvents = ( } else if (event?.name === 'RejectTransferHolder') { return { type: 'REJECT_TRANSFER_HOLDER', + holder: event.args?.toHolder, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, remark: event.args?.remark, } as TitleEscrowTransferEvent; } else if (event?.name === 'Shred') { + // New ABI: lastBeneficiary/lastHolder on Shred. Old ABI: leave unset (carry-forward fallback). + const lastBeneficiary = event.args?.lastBeneficiary as string | undefined; + const lastHolder = event.args?.lastHolder as string | undefined; return { type: 'RETURN_TO_ISSUER_ACCEPTED', blockNumber: event.blockNumber, from: tokenRegistryAddress, to: '0x00000000000000000000000000000000000dead', + owner: lastBeneficiary, + holder: lastHolder, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, remark: event.args?.remark, + terminationReason: toTerminationReasonLabel(event.args?.reason), } as TokenTransferEvent; } else if (event?.name === 'StatusInitialized') { return { @@ -381,6 +410,7 @@ const mapParsedLogsToEvents = ( } else if (event?.name === 'StatusAccepted') { return { type: 'STATUS_ACCEPTED', + holder: event.args?.holder, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, @@ -389,6 +419,7 @@ const mapParsedLogsToEvents = ( } else if (event?.name === 'StatusRejected') { return { type: 'STATUS_REJECTED', + holder: event.args?.holder, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, @@ -397,6 +428,7 @@ const mapParsedLogsToEvents = ( } else if (event?.name === 'StatusDischarged') { return { type: 'STATUS_DISCHARGED', + owner: event.args?.beneficiary, blockNumber: event.blockNumber, transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, diff --git a/src/core/endorsement-chain/helpers.ts b/src/core/endorsement-chain/helpers.ts index c5601b2..119136c 100644 --- a/src/core/endorsement-chain/helpers.ts +++ b/src/core/endorsement-chain/helpers.ts @@ -15,12 +15,18 @@ export const fetchEventTime = async ( /* Get available owner/holder from list of events */ +export const isZeroAddress = (address?: string): boolean => { + if (!address) return true; + return /^0x0{40}$/i.test(address); +}; + const getHolderOwner = (events: TransferBaseEvent[]): { owner: string; holder: string } => { let owner = ''; let holder = ''; for (const event of events) { - owner = event.owner || owner; - holder = event.holder || holder; + // Skip burn/zero addresses so shred companion transfers keep the last real parties. + if (event.owner && !isZeroAddress(event.owner)) owner = event.owner; + if (event.holder && !isZeroAddress(event.holder)) holder = event.holder; } return { owner, holder }; }; @@ -58,6 +64,14 @@ export const mergeTransfersV4 = (transferEvents: TransferBaseEvent[]): TransferB return mergedTransaction; }; +/** + * Non-empty on-chain remark hex (`0x` alone is empty). + * @param {string} [remark] Hex-encoded remark from an on-chain event + * @returns {boolean} True when remark has payload beyond the `0x` prefix + */ +const hasRemarkPayload = (remark?: string): boolean => + typeof remark === 'string' && remark.length > 2; + export const mergeTransfersV5 = (transferEvents: TransferBaseEvent[]): TransferBaseEvent[] => { const groupedEventsDict: Dictionary = groupBy( transferEvents, @@ -75,7 +89,14 @@ export const mergeTransfersV5 = (transferEvents: TransferBaseEvent[]): TransferB * for type TRANSFER_OWNERS, it does not exist, both TRANSFER_HOLDER and TRANSFER_BENEFICIARY will have same details, hence default to return first event */ const base = groupedEvents.find((event) => event.type === type) ?? groupedEvents[0]; - return [{ ...base, owner, holder, type }]; + // Obligation mint emits StatusInitialized (no remark) in the same tx as TokenReceived. + // Prefer any event that carries the encrypted remark payload. + const remark = + groupedEvents.find((event) => hasRemarkPayload(event.remark))?.remark ?? base.remark; + const terminationReason = groupedEvents.find( + (event) => event.terminationReason, + )?.terminationReason; + return [{ ...base, owner, holder, type, remark, terminationReason }]; } throw new Error('Invalid hash, update your configuration'); @@ -84,12 +105,22 @@ export const mergeTransfersV5 = (transferEvents: TransferBaseEvent[]): TransferB }; const identifyEventTypeFromLogs = (groupedEvents: TransferBaseEvent[]): TransferEventType => { + // Obligation mint emits StatusInitialized before TokenReceived(INITIAL) in log order. + // Prefer INITIAL so owner/holder/remarks match classic ETR mint display. + if (groupedEvents.some((event) => event.type === 'INITIAL')) { + return 'INITIAL'; + } + + // Reject/discharge auto-shred in the same tx as StatusRejected/Discharged. + // Prefer Shred so the chain row is RETURN_TO_ISSUER_ACCEPTED with last parties + reason. + if (groupedEvents.some((event) => event.type === 'RETURN_TO_ISSUER_ACCEPTED')) { + return 'RETURN_TO_ISSUER_ACCEPTED'; + } + for (const event of groupedEvents) { if ( [ - 'INITIAL', 'RETURNED_TO_ISSUER', - 'RETURN_TO_ISSUER_ACCEPTED', 'RETURN_TO_ISSUER_REJECTED', 'STATUS_INITIALIZED', 'STATUS_ACCEPTED', diff --git a/src/core/endorsement-chain/retrieveEndorsementChain.ts b/src/core/endorsement-chain/retrieveEndorsementChain.ts index 5e216d2..daa4dd2 100644 --- a/src/core/endorsement-chain/retrieveEndorsementChain.ts +++ b/src/core/endorsement-chain/retrieveEndorsementChain.ts @@ -1,8 +1,11 @@ import { ethers as ethersV6 } from 'ethersV6'; -import { fetchEventTime, sortLogChain } from '../endorsement-chain/helpers'; +import { fetchEventTime, isZeroAddress, sortLogChain } from '../endorsement-chain/helpers'; import { EndorsementChain, TransferBaseEvent, TransferEvent } from '../endorsement-chain/types'; import { Provider } from '@ethersproject/abstract-provider'; +const pickParty = (value: string | undefined, fallback: string): string => + value && !isZeroAddress(value) ? value : fallback; + /* Adds details of previous records (Previous Beneficiary/Holder) to current events history @@ -26,27 +29,40 @@ export const getEndorsementChain = async ( transactionHash: log.transactionHash, transactionIndex: log.transactionIndex, blockNumber: log.blockNumber, - owner: log.owner || previousBeneficiary, - holder: log.holder || previousHolder, + owner: pickParty(log.owner, previousBeneficiary), + holder: pickParty(log.holder, previousHolder), timestamp: timestamp, remark: log?.remark || '', + terminationReason: log.terminationReason, } as TransferEvent; if ( log.type === 'TRANSFER_OWNERS' || log.type === 'TRANSFER_BENEFICIARY' || log.type === 'TRANSFER_HOLDER' || - log.type === 'INITIAL' + log.type === 'INITIAL' || + log.type === 'REJECT_TRANSFER_OWNERS' || + log.type === 'REJECT_TRANSFER_BENEFICIARY' || + log.type === 'REJECT_TRANSFER_HOLDER' || + log.type === 'STATUS_INITIALIZED' || + log.type === 'STATUS_ACCEPTED' || + log.type === 'STATUS_REJECTED' || + log.type === 'STATUS_DISCHARGED' ) { - // Owner/Holder change + // Owner/Holder change (or carried forward for status / reject events) historyChain.push(transactionDetails); previousHolder = transactionDetails.holder; previousBeneficiary = transactionDetails.owner; } else if (log.type === 'SURRENDER_ACCEPTED' || log.type === 'RETURN_TO_ISSUER_ACCEPTED') { - // Title Escrow Voided + // Prefer Shred event lastBeneficiary/lastHolder when present; else carry previous (old ABI). + historyChain.push({ + ...transactionDetails, + owner: pickParty(log.owner, previousBeneficiary), + holder: pickParty(log.holder, previousHolder), + terminationReason: log.terminationReason, + }); previousHolder = ''; previousBeneficiary = ''; - historyChain.push(transactionDetails); } else if ( log.type === 'SURRENDERED' || log.type === 'SURRENDER_REJECTED' || diff --git a/src/core/endorsement-chain/types.ts b/src/core/endorsement-chain/types.ts index c1a5184..b224698 100644 --- a/src/core/endorsement-chain/types.ts +++ b/src/core/endorsement-chain/types.ts @@ -17,6 +17,8 @@ export type TradeTrustTokenEventType = export type TransferEventType = TokenTransferEventType | TitleEscrowTransferEventType; +export type TerminationReasonLabel = 'None' | 'ReturnToIssuer' | 'Rejected' | 'Discharged'; + export interface TransferBaseEvent { type: TransferEventType; transactionIndex: number; @@ -25,6 +27,8 @@ export interface TransferBaseEvent { transactionHash: string; blockNumber: number; remark?: string; + /** Present on shred (RETURN_TO_ISSUER_ACCEPTED) for ObligationEscrow */ + terminationReason?: TerminationReasonLabel; } export type TokenTransferEventType = @@ -62,6 +66,7 @@ export interface TransferEvent extends TransferBaseEvent { timestamp: number; holder: string; owner: string; + terminationReason?: TerminationReasonLabel; } export type EndorsementChain = TransferEvent[]; From ffb9d3d00564182aa8d090ad720a0d22321dfeb4 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 12:08:41 +0530 Subject: [PATCH 09/22] chore: update @tradetrust-tt/token-registry-v5 to version 5.6.0-beta.3 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index af28189..6b1648a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@tradetrust-tt/dnsprove": "^2.18.0", "@tradetrust-tt/ethers-aws-kms-signer": "^2.1.4", "@tradetrust-tt/token-registry-v4": "npm:@tradetrust-tt/token-registry@^4.16.0", - "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.6.0-beta.2", + "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.6.0-beta.3", "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", @@ -6638,9 +6638,9 @@ }, "node_modules/@tradetrust-tt/token-registry-v5": { "name": "@tradetrust-tt/token-registry", - "version": "5.6.0-beta.2", - "resolved": "https://registry.npmjs.org/@tradetrust-tt/token-registry/-/token-registry-5.6.0-beta.2.tgz", - "integrity": "sha512-bGrHYvHL8bV/mixU3fz00VEgpZmJBxGwG7EnfyBvOgYz3pKMdt7wZX55ckYuj+APFPMQG5DMenR+hr3WC+ZVyQ==", + "version": "5.6.0-beta.3", + "resolved": "https://registry.npmjs.org/@tradetrust-tt/token-registry/-/token-registry-5.6.0-beta.3.tgz", + "integrity": "sha512-1YJDuuBN5P2mx94aVrhbQ3wXt0Kb9eqIPjabHeZCkFjH87ZV1rtdXTmy1T2Af8Y7zROCWAD1V/8JpCFqC3u/JA==", "license": "Apache-2.0", "dependencies": { "ethers": "^6.13.4" diff --git a/package.json b/package.json index c433347..5996659 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "@tradetrust-tt/dnsprove": "^2.18.0", "@tradetrust-tt/ethers-aws-kms-signer": "^2.1.4", "@tradetrust-tt/token-registry-v4": "npm:@tradetrust-tt/token-registry@^4.16.0", - "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.6.0-beta.2", + "@tradetrust-tt/token-registry-v5": "npm:@tradetrust-tt/token-registry@^5.6.0-beta.3", "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", From c28a659edd676fe73229194ec63bf00ea9ce5f01 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 12:11:51 +0530 Subject: [PATCH 10/22] fix: remove optional terminationReason from TransferEvent interface --- src/core/endorsement-chain/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/endorsement-chain/types.ts b/src/core/endorsement-chain/types.ts index b224698..c453c51 100644 --- a/src/core/endorsement-chain/types.ts +++ b/src/core/endorsement-chain/types.ts @@ -66,7 +66,6 @@ export interface TransferEvent extends TransferBaseEvent { timestamp: number; holder: string; owner: string; - terminationReason?: TerminationReasonLabel; } export type EndorsementChain = TransferEvent[]; From 7bd32c025d5cf79f0a5f17b9c63ae7da92a4901d Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 12:28:02 +0530 Subject: [PATCH 11/22] refactor: simplify terminationReason handling in endorsement chain events --- .../endorsement-chain/fetchEscrowTransfer.ts | 3 ++- src/core/endorsement-chain/helpers.ts | 11 ++++++++++- .../retrieveEndorsementChain.ts | 18 +++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 04c50ae..0d35f94 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -387,6 +387,7 @@ const mapParsedLogsToEvents = ( // New ABI: lastBeneficiary/lastHolder on Shred. Old ABI: leave unset (carry-forward fallback). const lastBeneficiary = event.args?.lastBeneficiary as string | undefined; const lastHolder = event.args?.lastHolder as string | undefined; + const terminationReason = toTerminationReasonLabel(event.args?.reason); return { type: 'RETURN_TO_ISSUER_ACCEPTED', blockNumber: event.blockNumber, @@ -397,7 +398,7 @@ const mapParsedLogsToEvents = ( transactionHash: event.transactionHash, transactionIndex: event.transactionIndex, remark: event.args?.remark, - terminationReason: toTerminationReasonLabel(event.args?.reason), + ...(terminationReason ? { terminationReason } : {}), } as TokenTransferEvent; } else if (event?.name === 'StatusInitialized') { return { diff --git a/src/core/endorsement-chain/helpers.ts b/src/core/endorsement-chain/helpers.ts index 119136c..9ac6c25 100644 --- a/src/core/endorsement-chain/helpers.ts +++ b/src/core/endorsement-chain/helpers.ts @@ -96,7 +96,16 @@ export const mergeTransfersV5 = (transferEvents: TransferBaseEvent[]): TransferB const terminationReason = groupedEvents.find( (event) => event.terminationReason, )?.terminationReason; - return [{ ...base, owner, holder, type, remark, terminationReason }]; + return [ + { + ...base, + owner, + holder, + type, + remark, + ...(terminationReason ? { terminationReason } : {}), + }, + ]; } throw new Error('Invalid hash, update your configuration'); diff --git a/src/core/endorsement-chain/retrieveEndorsementChain.ts b/src/core/endorsement-chain/retrieveEndorsementChain.ts index daa4dd2..2855780 100644 --- a/src/core/endorsement-chain/retrieveEndorsementChain.ts +++ b/src/core/endorsement-chain/retrieveEndorsementChain.ts @@ -33,7 +33,7 @@ export const getEndorsementChain = async ( holder: pickParty(log.holder, previousHolder), timestamp: timestamp, remark: log?.remark || '', - terminationReason: log.terminationReason, + ...(log.terminationReason ? { terminationReason: log.terminationReason } : {}), } as TransferEvent; if ( @@ -54,12 +54,20 @@ export const getEndorsementChain = async ( previousHolder = transactionDetails.holder; previousBeneficiary = transactionDetails.owner; } else if (log.type === 'SURRENDER_ACCEPTED' || log.type === 'RETURN_TO_ISSUER_ACCEPTED') { - // Prefer Shred event lastBeneficiary/lastHolder when present; else carry previous (old ABI). + // Obligation Shred carries lastBeneficiary/lastHolder on the event. + // Classic TitleEscrow shred has no parties — keep zero addresses (fixture / ETR UI). + const owner = + log.owner && !isZeroAddress(log.owner) + ? log.owner + : '0x0000000000000000000000000000000000000000'; + const holder = + log.holder && !isZeroAddress(log.holder) + ? log.holder + : '0x0000000000000000000000000000000000000000'; historyChain.push({ ...transactionDetails, - owner: pickParty(log.owner, previousBeneficiary), - holder: pickParty(log.holder, previousHolder), - terminationReason: log.terminationReason, + owner, + holder, }); previousHolder = ''; previousBeneficiary = ''; From 086e5cc16ae9de7320e9ec323304598718c6b19c Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 12:48:47 +0530 Subject: [PATCH 12/22] fix: update owner addresses in endorsement chain test cases --- src/__tests__/fixtures/endorsement-chain.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/__tests__/fixtures/endorsement-chain.ts b/src/__tests__/fixtures/endorsement-chain.ts index 3c9087b..1d3c810 100644 --- a/src/__tests__/fixtures/endorsement-chain.ts +++ b/src/__tests__/fixtures/endorsement-chain.ts @@ -1334,7 +1334,7 @@ export const testCases = [ { blockNumber: 15068712, holder: '0xCA93690Bb57EEaB273c796a9309246BC0FB93649', - owner: '0xe0A71284EF59483795053266CB796B65E48B5124', + owner: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', remark: 'Transfer Holdership', timestamp: 1732987703000, transactionHash: '0xadb9231bece27ae3aac4e2483752046014e983b80d54dfc490e3459da451dbfa', @@ -1364,7 +1364,7 @@ export const testCases = [ { blockNumber: 15069476, holder: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', - owner: '0xe0A71284EF59483795053266CB796B65E48B5124', + owner: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', remark: 'Transfer Holder', timestamp: 1732989327000, transactionHash: '0x2d53578ffe1889dd82eecd5e923dabb06b57eb67a2d16e2c8b210e02b398c5c5', @@ -1374,7 +1374,7 @@ export const testCases = [ { blockNumber: 15069490, holder: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', - owner: '0xe0A71284EF59483795053266CB796B65E48B5124', + owner: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', remark: 'Return To Issuer', timestamp: 1732989357000, transactionHash: '0x8e575e2a281d3bce5d6e4b6298e1e54b9c49bad0ef135ae9e68fc9d02ccc1ba1', @@ -1384,7 +1384,7 @@ export const testCases = [ { blockNumber: 15069501, holder: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', - owner: '0xe0A71284EF59483795053266CB796B65E48B5124', + owner: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', remark: 'Reject Return To Issuer', timestamp: 1732989379000, transactionHash: '0x3bf456d1fa29e4b7cfc3cbfc3a568f5c2c4e1dd8454d25f8822eea3b65c66956', @@ -1394,7 +1394,7 @@ export const testCases = [ { blockNumber: 15069511, holder: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', - owner: '0xe0A71284EF59483795053266CB796B65E48B5124', + owner: '0x433097a1C1b8a3e9188d8C54eCC057B1D69f1638', remark: 'Return To Issuer', timestamp: 1732989401000, transactionHash: '0x99e56c4a1ddcf1a8031402a46eb41b4c41f1379f420287aaa57cad0e18ed85ce', From 134d312656ee9c50d99bfbb0abc0ebc482089eba Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 14:23:58 +0530 Subject: [PATCH 13/22] fix: correct alias name in CLAUDE.md and update dead address format in fetchEscrowTransfer.ts --- CLAUDE.md | 2 +- src/core/endorsement-chain/fetchEscrowTransfer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c413e2..6b7e7fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ These public aliases were removed: | --- | --- | | `fetchObligationEndorsementChain` | `fetchEndorsementChain` | | `fetchEscrowTransfersObligation` | `fetchEscrowTransfersV5` (auto-detects obligation status events) | -| `ObligationEscrowInterface` | `v5SupportInterfaceIds.ObligationEscrow` | +| `ObligationEscrowInterface` | `supportInterfaceIdsV5.ObligationEscrow` | Do **not** re-add the removed aliases. User-facing docs also live in `README.md` (Obligation Registry section). diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 0d35f94..8cc5992 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -392,7 +392,7 @@ const mapParsedLogsToEvents = ( type: 'RETURN_TO_ISSUER_ACCEPTED', blockNumber: event.blockNumber, from: tokenRegistryAddress, - to: '0x00000000000000000000000000000000000dead', + to: '0x000000000000000000000000000000000000dEaD', owner: lastBeneficiary, holder: lastHolder, transactionHash: event.transactionHash, From b2025b24af01f15620c4fa6c2728b17210f858ba Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 14:46:01 +0530 Subject: [PATCH 14/22] Update src/core/endorsement-chain/fetchEscrowTransfer.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/core/endorsement-chain/fetchEscrowTransfer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 8cc5992..7fb3c4e 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -392,7 +392,7 @@ const mapParsedLogsToEvents = ( type: 'RETURN_TO_ISSUER_ACCEPTED', blockNumber: event.blockNumber, from: tokenRegistryAddress, - to: '0x000000000000000000000000000000000000dEaD', + to: '0x000000000000000000000000000000000000dead', owner: lastBeneficiary, holder: lastHolder, transactionHash: event.transactionHash, From ecb5fd744ce88dc6e3d61d483dac28f0677106f8 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 14:50:57 +0530 Subject: [PATCH 15/22] refactor: remove unused constants and simplify budget handling in fetchLogsChunked.ts --- src/constants.ts | 1 - .../endorsement-chain/fetchLogsChunked.ts | 393 +++--------------- 2 files changed, 63 insertions(+), 331 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 1fe0bf0..967330a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,7 +12,6 @@ export const INITIAL_CHUNK_SIZE = 10_000; export const FREE_TIER_MAX_CHUNK_SIZE = 10; export const MIN_CHUNK_SIZE = 1; export const MAX_CHUNK_SIZE = 50_000; -export const FREE_TIER_CONCURRENCY = 3; export const DEFAULT_MAX_BLOCKS_TO_SCAN = 200_000; export const FREE_TIER_MAX_REQUESTS = 5_000; export const FREE_TIER_MAX_DURATION_MS = 60_000; diff --git a/src/core/endorsement-chain/fetchLogsChunked.ts b/src/core/endorsement-chain/fetchLogsChunked.ts index 94ce39e..1fa0f90 100644 --- a/src/core/endorsement-chain/fetchLogsChunked.ts +++ b/src/core/endorsement-chain/fetchLogsChunked.ts @@ -2,7 +2,6 @@ import { ethers as ethersV6 } from 'ethersV6'; import { Provider } from '@ethersproject/abstract-provider'; import { DEFAULT_MAX_BLOCKS_TO_SCAN, - FREE_TIER_CONCURRENCY, FREE_TIER_MAX_CHUNK_SIZE, FREE_TIER_MAX_DURATION_MS, FREE_TIER_MAX_REQUESTS, @@ -49,10 +48,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function isRateLimitError(err: unknown): boolean { - return RATE_LIMIT_ERROR_RE.test(errorMessage(err)); -} - interface ScanLogsBackwardResult { // eslint-disable-next-line @typescript-eslint/no-explicit-any logs: any[]; @@ -63,15 +58,10 @@ interface ScanLogsBackwardResult { interface AdaptiveScanState { chunkSize: number; maxChunkSize: number; -} - -interface FreeTierBudget { requestsUsed: number; deadlineAt: number; } -type BlockWindow = { start: number; end: number }; - function shrinkForRangeLimit(state: AdaptiveScanState, message: string): void { if (INFURA_FREE_TIER_RANGE_RE.test(message)) { state.maxChunkSize = Math.min(state.maxChunkSize, FREE_TIER_MAX_CHUNK_SIZE); @@ -80,61 +70,8 @@ function shrinkForRangeLimit(state: AdaptiveScanState, message: string): void { state.chunkSize = Math.min(state.chunkSize, state.maxChunkSize); } -class BudgetExhaustedError extends Error { - constructor(message: string) { - super(message); - this.name = 'BudgetExhaustedError'; - } -} - -function isBudgetExhaustedError(err: unknown): boolean { - return ( - err instanceof BudgetExhaustedError || - (err instanceof Error && err.name === 'BudgetExhaustedError') - ); -} - -function assertBudgets(budget: FreeTierBudget, upcoming = 0): void { - if (Date.now() >= budget.deadlineAt) { - throw new BudgetExhaustedError( - `RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`, - ); - } - if (budget.requestsUsed + upcoming > FREE_TIER_MAX_REQUESTS) { - throw new BudgetExhaustedError( - `RPC scan request budget exhausted (${FREE_TIER_MAX_REQUESTS} eth_getLogs calls)`, - ); - } -} - -function withDeadline(promise: Promise, deadlineAt: number): Promise { - const remaining = deadlineAt - Date.now(); - if (remaining <= 0) { - return Promise.reject( - new BudgetExhaustedError( - `RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`, - ), - ); - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject( - new BudgetExhaustedError( - `RPC scan time budget exhausted after ${FREE_TIER_MAX_DURATION_MS}ms`, - ), - ); - }, remaining); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (err) => { - clearTimeout(timer); - reject(err); - }, - ); - }); +function isBudgetExhausted(state: AdaptiveScanState): boolean { + return Date.now() >= state.deadlineAt || state.requestsUsed >= FREE_TIER_MAX_REQUESTS; } async function getLogsRange( @@ -142,44 +79,21 @@ async function getLogsRange( address: string, fromBlock: number, toBlock: number, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Promise { - let attempt = 0; - while (true) { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (await provider.getLogs({ address, fromBlock, toBlock })) as any[]; - } catch (err) { - if (isRateLimitError(err) && attempt < RATE_LIMIT_MAX_RETRIES) { - await sleep(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt); - attempt += 1; - continue; - } - throw err; - } - } -} - -async function getLogsRangeFreeTier( - provider: Provider | ethersV6.Provider, - address: string, - fromBlock: number, - toBlock: number, - budget: FreeTierBudget, + state: AdaptiveScanState, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { for (let attempt = 0; ; attempt++) { - assertBudgets(budget); - budget.requestsUsed += 1; + if (isBudgetExhausted(state)) { + throw new Error('RPC scan budget exhausted'); + } + state.requestsUsed += 1; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pending = provider.getLogs({ address, fromBlock, toBlock }) as Promise; - return await withDeadline(pending, budget.deadlineAt); + return (await provider.getLogs({ address, fromBlock, toBlock })) as any[]; } catch (err) { - if (isRateLimitError(err) && attempt < RATE_LIMIT_MAX_RETRIES) { - assertBudgets(budget); + if (RATE_LIMIT_ERROR_RE.test(errorMessage(err)) && attempt < RATE_LIMIT_MAX_RETRIES) { await sleep( - Math.min(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt, budget.deadlineAt - Date.now()), + Math.min(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt, state.deadlineAt - Date.now()), ); continue; } @@ -188,6 +102,7 @@ async function getLogsRangeFreeTier( } } +// Keep mint and any same-tx companion logs that precede it (e.g. StatusInitialized). // eslint-disable-next-line @typescript-eslint/no-explicit-any function findMintSliceStart(logs: any[], isMintLog: (log: any) => boolean): number { let mintIndex = -1; @@ -207,209 +122,18 @@ function findMintSliceStart(logs: any[], isMintLog: (log: any) => boolean): numb return start; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function flattenOldestFirst(chunkGroups: any[][]): any[] { - return chunkGroups.toReversed().flat(); -} - -function pushMintSliceIfFound( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - chunkLogs: any[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isMintLog: ((log: any) => boolean) | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - groups: any[][], -): boolean { - if (!isMintLog) return false; - const start = findMintSliceStart(chunkLogs, isMintLog); - if (start < 0) return false; - groups.push(chunkLogs.slice(start)); - return true; -} - -function buildParallelWindows( - cursor: number, - toBlockFloor: number, - windowSize: number, -): BlockWindow[] { - const windows: BlockWindow[] = []; - for (let winCursor = cursor, i = 0; i < FREE_TIER_CONCURRENCY && winCursor >= toBlockFloor; i++) { - const start = Math.max(winCursor - windowSize + 1, toBlockFloor); - windows.push({ start, end: winCursor }); - if (start <= toBlockFloor) break; - winCursor = start - 1; - } - return windows; -} - -function processSettledBatch( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - settled: PromiseSettledResult[], - windowSize: number, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): { results: any[][]; rangeTooLarge: boolean; budgetExhausted: boolean; hardError?: unknown } { - let rangeTooLarge = false; - let budgetExhausted = false; - let hardError: unknown; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const results: any[][] = new Array(settled.length); - - for (let i = 0; i < settled.length; i++) { - const outcome = settled[i]; - if (outcome.status === 'fulfilled') { - results[i] = outcome.value; - continue; - } - if (isBudgetExhaustedError(outcome.reason)) { - budgetExhausted = true; - continue; - } - const message = errorMessage(outcome.reason); - if (RANGE_TOO_LARGE_ERROR_RE.test(message) && windowSize > MIN_CHUNK_SIZE) { - rangeTooLarge = true; - } else if (!hardError) { - hardError = outcome.reason; - } - } - - return { results, rangeTooLarge, budgetExhausted, hardError }; -} - -function collectBatchChunks( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - results: any[][], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isMintLog: ((log: any) => boolean) | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - chunkGroups: any[][], -): boolean { - for (const chunkLogs of results) { - if (!chunkLogs) continue; - if (pushMintSliceIfFound(chunkLogs, isMintLog, chunkGroups)) { - return true; - } - chunkGroups.push(chunkLogs); - } - return false; -} - -const scanLogsBackwardParallel = async ( - provider: Provider | ethersV6.Provider, - address: string, - fromBlock: number, - toBlockFloor: number, - chunkSize: number, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isMintLog?: (log: any) => boolean, -): Promise => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const chunkGroups: any[][] = []; - let cursor = fromBlock; - let windowSize = Math.max(Math.min(chunkSize, FREE_TIER_MAX_CHUNK_SIZE), MIN_CHUNK_SIZE); - const budget: FreeTierBudget = { - requestsUsed: 0, - deadlineAt: Date.now() + FREE_TIER_MAX_DURATION_MS, - }; - - while (cursor >= toBlockFloor) { - const windows = buildParallelWindows(cursor, toBlockFloor, windowSize); - try { - assertBudgets(budget, windows.length); - } catch (err) { - if (isBudgetExhaustedError(err)) { - return { logs: flattenOldestFirst(chunkGroups), foundMint: false, truncated: true }; - } - throw err; - } - - const settled = await Promise.allSettled( - windows.map(({ start, end }) => getLogsRangeFreeTier(provider, address, start, end, budget)), - ); - const { results, rangeTooLarge, budgetExhausted, hardError } = processSettledBatch( - settled, - windowSize, - ); - - if (hardError) throw hardError; - if (rangeTooLarge && !budgetExhausted) { - windowSize = Math.max(Math.floor(windowSize / 4), MIN_CHUNK_SIZE); - continue; - } - - if (collectBatchChunks(results, isMintLog, chunkGroups)) { - return { logs: flattenOldestFirst(chunkGroups), foundMint: true, truncated: false }; - } - if (budgetExhausted) { - return { logs: flattenOldestFirst(chunkGroups), foundMint: false, truncated: true }; - } - - const oldest = windows[windows.length - 1]; - if (oldest.start <= toBlockFloor) break; - cursor = oldest.start - 1; - } - - return { - logs: flattenOldestFirst(chunkGroups), - foundMint: false, - truncated: Boolean(isMintLog), - }; -}; - -async function handoffToFreeTier( - provider: Provider | ethersV6.Provider, - address: string, - cursor: number, - effectiveFloor: number, - chunkSize: number, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isMintLog: ((log: any) => boolean) | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - newerChunkGroups: any[][], -): Promise { - const older = await scanLogsBackwardParallel( - provider, - address, - cursor, - effectiveFloor, - chunkSize, - isMintLog, - ); - return { - logs: [...older.logs, ...flattenOldestFirst(newerChunkGroups)], - foundMint: older.foundMint, - truncated: older.foundMint ? false : older.truncated, - }; -} - -async function fetchPaidTierChunk( - provider: Provider | ethersV6.Provider, - address: string, - cursor: number, - effectiveFloor: number, - state: AdaptiveScanState, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - isMintLog: ((log: any) => boolean) | undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - newerChunkGroups: any[][], -): Promise<'mint' | 'continue' | 'done'> { - const chunkStart = Math.max(cursor - state.chunkSize + 1, effectiveFloor); - try { - const chunkLogs = await getLogsRange(provider, address, chunkStart, cursor); - if (pushMintSliceIfFound(chunkLogs, isMintLog, newerChunkGroups)) { - return 'mint'; - } - newerChunkGroups.push(chunkLogs); - } catch (err) { - const message = errorMessage(err); - if (RANGE_TOO_LARGE_ERROR_RE.test(message) && state.chunkSize > MIN_CHUNK_SIZE) { - shrinkForRangeLimit(state, message); - return 'continue'; - } - throw err; - } - return chunkStart <= effectiveFloor ? 'done' : 'continue'; -} - +/** + * Adaptive backward eth_getLogs scanner. + * Starts with a large window, shrinks on provider range limits (including Infura's 10-block + * free-tier cap), retries rate limits, and stops early when isMintLog matches. + * @param {Provider | ethersV6.Provider} provider - Ethers provider + * @param {string} address - Contract address to scan + * @param {number} fromBlock - Latest block to start from + * @param {number} toBlockFloor - Earliest block to stop at + * @param {(log: any) => boolean} [isMintLog] - Optional mint detector to stop early + * @param {number} [maxBlocksToScan] - Max blocks to walk back from fromBlock + * @returns {Promise} Logs oldest→newest plus mint/truncation flags + */ export const scanLogsBackward = async ( provider: Provider | ethersV6.Provider, address: string, @@ -420,10 +144,12 @@ export const scanLogsBackward = async ( maxBlocksToScan: number = DEFAULT_MAX_BLOCKS_TO_SCAN, ): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newerChunkGroups: any[][] = []; + const chunkGroups: any[][] = []; const state: AdaptiveScanState = { chunkSize: Math.min(INITIAL_CHUNK_SIZE, MAX_CHUNK_SIZE), maxChunkSize: MAX_CHUNK_SIZE, + requestsUsed: 0, + deadlineAt: Date.now() + FREE_TIER_MAX_DURATION_MS, }; const budgetFloor = Math.max(0, fromBlock - maxBlocksToScan); const effectiveFloor = Math.max(toBlockFloor, budgetFloor); @@ -431,44 +157,51 @@ export const scanLogsBackward = async ( let cursor = fromBlock; while (cursor >= effectiveFloor) { - if (state.maxChunkSize <= FREE_TIER_MAX_CHUNK_SIZE) { - return handoffToFreeTier( - provider, - address, - cursor, - effectiveFloor, - state.chunkSize, - isMintLog, - newerChunkGroups, - ); - } - - const priorChunkSize = state.chunkSize; - const outcome = await fetchPaidTierChunk( - provider, - address, - cursor, - effectiveFloor, - state, - isMintLog, - newerChunkGroups, - ); - if (outcome === 'mint') { + if (isBudgetExhausted(state)) { return { - logs: flattenOldestFirst(newerChunkGroups), - foundMint: true, - truncated: false, + logs: chunkGroups.toReversed().flat(), + foundMint: false, + truncated: true, }; } - if (state.chunkSize !== priorChunkSize) continue; - const chunkStart = Math.max(cursor - priorChunkSize + 1, effectiveFloor); - if (outcome === 'done' || chunkStart <= effectiveFloor) break; + const chunkStart = Math.max(cursor - state.chunkSize + 1, effectiveFloor); + try { + const chunkLogs = await getLogsRange(provider, address, chunkStart, cursor, state); + if (isMintLog) { + const start = findMintSliceStart(chunkLogs, isMintLog); + if (start >= 0) { + chunkGroups.push(chunkLogs.slice(start)); + return { + logs: chunkGroups.toReversed().flat(), + foundMint: true, + truncated: false, + }; + } + } + chunkGroups.push(chunkLogs); + } catch (err) { + if (err instanceof Error && err.message === 'RPC scan budget exhausted') { + return { + logs: chunkGroups.toReversed().flat(), + foundMint: false, + truncated: true, + }; + } + const message = errorMessage(err); + if (RANGE_TOO_LARGE_ERROR_RE.test(message) && state.chunkSize > MIN_CHUNK_SIZE) { + shrinkForRangeLimit(state, message); + continue; + } + throw err; + } + + if (chunkStart <= effectiveFloor) break; cursor = chunkStart - 1; } return { - logs: flattenOldestFirst(newerChunkGroups), + logs: chunkGroups.toReversed().flat(), foundMint: false, truncated: Boolean(isMintLog) && budgetRaisedFloor, }; From 9a47ed996c66d5ecccfa7236e1277eb6fedba49b Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 15:05:00 +0530 Subject: [PATCH 16/22] Update src/core/endorsement-chain/fetchLogsChunked.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/core/endorsement-chain/fetchLogsChunked.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/endorsement-chain/fetchLogsChunked.ts b/src/core/endorsement-chain/fetchLogsChunked.ts index 1fa0f90..e03dd39 100644 --- a/src/core/endorsement-chain/fetchLogsChunked.ts +++ b/src/core/endorsement-chain/fetchLogsChunked.ts @@ -93,7 +93,7 @@ async function getLogsRange( } catch (err) { if (RATE_LIMIT_ERROR_RE.test(errorMessage(err)) && attempt < RATE_LIMIT_MAX_RETRIES) { await sleep( - Math.min(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt, state.deadlineAt - Date.now()), + Math.max(0, Math.min(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt, state.deadlineAt - Date.now())), ); continue; } From 0dbe40dac5b861224d204672055ab7967c8c05c7 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 15:08:02 +0530 Subject: [PATCH 17/22] style: format code for better readability in fetchLogsChunked.ts --- src/core/endorsement-chain/fetchLogsChunked.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/endorsement-chain/fetchLogsChunked.ts b/src/core/endorsement-chain/fetchLogsChunked.ts index e03dd39..c9ede96 100644 --- a/src/core/endorsement-chain/fetchLogsChunked.ts +++ b/src/core/endorsement-chain/fetchLogsChunked.ts @@ -93,7 +93,10 @@ async function getLogsRange( } catch (err) { if (RATE_LIMIT_ERROR_RE.test(errorMessage(err)) && attempt < RATE_LIMIT_MAX_RETRIES) { await sleep( - Math.max(0, Math.min(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt, state.deadlineAt - Date.now())), + Math.max( + 0, + Math.min(RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt, state.deadlineAt - Date.now()), + ), ); continue; } From d14425ef1ccb80e1849e1ec6de865abf2bf72d7d Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 15:22:16 +0530 Subject: [PATCH 18/22] refactor: streamline event mapping and log handling --- .../endorsement-chain/fetchEscrowTransfer.ts | 246 ++++++++---------- .../endorsement-chain/fetchLogsChunked.ts | 80 +++--- 2 files changed, 165 insertions(+), 161 deletions(-) diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index 7fb3c4e..dcc3b37 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -301,144 +301,126 @@ const fetchEscrowLogs = async ( } }; +const logMeta = (event: ParsedLog) => ({ + blockNumber: event.blockNumber, + transactionHash: event.transactionHash, + transactionIndex: event.transactionIndex, + remark: event.args?.remark, +}); + +const mapTokenReceivedEvent = ( + event: ParsedLog, + titleEscrowAddress: string, + tokenRegistryAddress: string, +): TokenTransferEvent => { + const type = identifyTokenReceivedType(event); + return { + type, + from: type === 'INITIAL' ? '0x0000000000000000000000000000000000000000' : tokenRegistryAddress, + to: titleEscrowAddress, + // TokenReceived carries beneficiary/holder — needed when merge prefers INITIAL. + owner: event.args?.beneficiary, + holder: event.args?.holder, + ...logMeta(event), + } as TokenTransferEvent; +}; + +const mapShredEvent = (event: ParsedLog, tokenRegistryAddress: string): TokenTransferEvent => { + // New ABI: lastBeneficiary/lastHolder on Shred. Old ABI: leave unset (carry-forward fallback). + const terminationReason = toTerminationReasonLabel(event.args?.reason); + return { + type: 'RETURN_TO_ISSUER_ACCEPTED', + from: tokenRegistryAddress, + to: '0x000000000000000000000000000000000000dead', + owner: event.args?.lastBeneficiary as string | undefined, + holder: event.args?.lastHolder as string | undefined, + ...logMeta(event), + ...(terminationReason ? { terminationReason } : {}), + } as TokenTransferEvent; +}; + +const mapParsedLogToEvent = ( + event: ParsedLog, + titleEscrowAddress: string, + tokenRegistryAddress: string, +): TitleEscrowTransferEvent | TokenTransferEvent | undefined => { + switch (event?.name) { + case 'HolderTransfer': + return { + type: 'TRANSFER_HOLDER', + holder: event.args.toHolder, + ...logMeta(event), + } as TitleEscrowTransferEvent; + case 'BeneficiaryTransfer': + return { + type: 'TRANSFER_BENEFICIARY', + owner: event.args.toBeneficiary, + ...logMeta(event), + } as TitleEscrowTransferEvent; + case 'TokenReceived': + return mapTokenReceivedEvent(event, titleEscrowAddress, tokenRegistryAddress); + case 'ReturnToIssuer': + return { + type: 'RETURNED_TO_ISSUER', + from: titleEscrowAddress, + to: tokenRegistryAddress, + ...logMeta(event), + } as TokenTransferEvent; + case 'Nomination': + return undefined; + case 'RejectTransferOwners': + return { + type: 'REJECT_TRANSFER_OWNERS', + owner: event.args?.toBeneficiary, + holder: event.args?.toHolder, + ...logMeta(event), + } as TitleEscrowTransferEvent; + case 'RejectTransferBeneficiary': + return { + type: 'REJECT_TRANSFER_BENEFICIARY', + owner: event.args?.toBeneficiary, + ...logMeta(event), + } as TitleEscrowTransferEvent; + case 'RejectTransferHolder': + return { + type: 'REJECT_TRANSFER_HOLDER', + holder: event.args?.toHolder, + ...logMeta(event), + } as TitleEscrowTransferEvent; + case 'Shred': + return mapShredEvent(event, tokenRegistryAddress); + case 'StatusInitialized': + return { type: 'STATUS_INITIALIZED', ...logMeta(event) } as TitleEscrowTransferEvent; + case 'StatusAccepted': + return { + type: 'STATUS_ACCEPTED', + holder: event.args?.holder, + ...logMeta(event), + } as TitleEscrowTransferEvent; + case 'StatusRejected': + return { + type: 'STATUS_REJECTED', + holder: event.args?.holder, + ...logMeta(event), + } as TitleEscrowTransferEvent; + case 'StatusDischarged': + return { + type: 'STATUS_DISCHARGED', + owner: event.args?.beneficiary, + ...logMeta(event), + } as TitleEscrowTransferEvent; + default: + return undefined; + } +}; + const mapParsedLogsToEvents = ( holderChangeLogsParsed: ParsedLog[], titleEscrowAddress: string, tokenRegistryAddress: string, ): (TitleEscrowTransferEvent | TokenTransferEvent)[] => { return holderChangeLogsParsed - .map((event) => { - if (event?.name === 'HolderTransfer') { - return { - type: 'TRANSFER_HOLDER', - blockNumber: event.blockNumber, - holder: event.args.toHolder, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'BeneficiaryTransfer') { - return { - type: 'TRANSFER_BENEFICIARY', - owner: event.args.toBeneficiary, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'TokenReceived') { - const type = identifyTokenReceivedType(event); - return { - type, - from: - type === 'INITIAL' - ? '0x0000000000000000000000000000000000000000' - : tokenRegistryAddress, - to: titleEscrowAddress, - // TokenReceived carries beneficiary/holder — needed when merge prefers INITIAL. - owner: event.args?.beneficiary, - holder: event.args?.holder, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TokenTransferEvent; - } else if (event?.name === 'ReturnToIssuer') { - return { - type: 'RETURNED_TO_ISSUER', - blockNumber: event.blockNumber, - from: titleEscrowAddress, - to: tokenRegistryAddress, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TokenTransferEvent; - } else if (event?.name === 'Nomination') { - return undefined; - } else if (event?.name === 'RejectTransferOwners') { - return { - type: 'REJECT_TRANSFER_OWNERS', - owner: event.args?.toBeneficiary, - holder: event.args?.toHolder, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'RejectTransferBeneficiary') { - return { - type: 'REJECT_TRANSFER_BENEFICIARY', - owner: event.args?.toBeneficiary, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'RejectTransferHolder') { - return { - type: 'REJECT_TRANSFER_HOLDER', - holder: event.args?.toHolder, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'Shred') { - // New ABI: lastBeneficiary/lastHolder on Shred. Old ABI: leave unset (carry-forward fallback). - const lastBeneficiary = event.args?.lastBeneficiary as string | undefined; - const lastHolder = event.args?.lastHolder as string | undefined; - const terminationReason = toTerminationReasonLabel(event.args?.reason); - return { - type: 'RETURN_TO_ISSUER_ACCEPTED', - blockNumber: event.blockNumber, - from: tokenRegistryAddress, - to: '0x000000000000000000000000000000000000dead', - owner: lastBeneficiary, - holder: lastHolder, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - ...(terminationReason ? { terminationReason } : {}), - } as TokenTransferEvent; - } else if (event?.name === 'StatusInitialized') { - return { - type: 'STATUS_INITIALIZED', - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'StatusAccepted') { - return { - type: 'STATUS_ACCEPTED', - holder: event.args?.holder, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'StatusRejected') { - return { - type: 'STATUS_REJECTED', - holder: event.args?.holder, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'StatusDischarged') { - return { - type: 'STATUS_DISCHARGED', - owner: event.args?.beneficiary, - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } - - return undefined; - }) + .map((event) => mapParsedLogToEvent(event, titleEscrowAddress, tokenRegistryAddress)) .filter((event) => event !== undefined) as (TitleEscrowTransferEvent | TokenTransferEvent)[]; }; diff --git a/src/core/endorsement-chain/fetchLogsChunked.ts b/src/core/endorsement-chain/fetchLogsChunked.ts index c9ede96..7c3749f 100644 --- a/src/core/endorsement-chain/fetchLogsChunked.ts +++ b/src/core/endorsement-chain/fetchLogsChunked.ts @@ -125,6 +125,50 @@ function findMintSliceStart(logs: any[], isMintLog: (log: any) => boolean): numb return start; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function flattenOldestFirst(chunkGroups: any[][]): any[] { + return chunkGroups.toReversed().flat(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function truncatedScanResult(chunkGroups: any[][]): ScanLogsBackwardResult { + return { logs: flattenOldestFirst(chunkGroups), foundMint: false, truncated: true }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mintScanResult(chunkGroups: any[][]): ScanLogsBackwardResult { + return { logs: flattenOldestFirst(chunkGroups), foundMint: true, truncated: false }; +} + +function tryCollectMintSlice( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chunkLogs: any[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMintLog: ((log: any) => boolean) | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chunkGroups: any[][], +): boolean { + if (!isMintLog) return false; + const start = findMintSliceStart(chunkLogs, isMintLog); + if (start < 0) return false; + chunkGroups.push(chunkLogs.slice(start)); + return true; +} + +type ScanChunkErrorOutcome = 'truncated' | 'retry'; + +function handleScanChunkError(err: unknown, state: AdaptiveScanState): ScanChunkErrorOutcome { + if (err instanceof Error && err.message === 'RPC scan budget exhausted') { + return 'truncated'; + } + const message = errorMessage(err); + if (RANGE_TOO_LARGE_ERROR_RE.test(message) && state.chunkSize > MIN_CHUNK_SIZE) { + shrinkForRangeLimit(state, message); + return 'retry'; + } + throw err; +} + /** * Adaptive backward eth_getLogs scanner. * Starts with a large window, shrinks on provider range limits (including Infura's 10-block @@ -161,42 +205,20 @@ export const scanLogsBackward = async ( while (cursor >= effectiveFloor) { if (isBudgetExhausted(state)) { - return { - logs: chunkGroups.toReversed().flat(), - foundMint: false, - truncated: true, - }; + return truncatedScanResult(chunkGroups); } const chunkStart = Math.max(cursor - state.chunkSize + 1, effectiveFloor); try { const chunkLogs = await getLogsRange(provider, address, chunkStart, cursor, state); - if (isMintLog) { - const start = findMintSliceStart(chunkLogs, isMintLog); - if (start >= 0) { - chunkGroups.push(chunkLogs.slice(start)); - return { - logs: chunkGroups.toReversed().flat(), - foundMint: true, - truncated: false, - }; - } + if (tryCollectMintSlice(chunkLogs, isMintLog, chunkGroups)) { + return mintScanResult(chunkGroups); } chunkGroups.push(chunkLogs); } catch (err) { - if (err instanceof Error && err.message === 'RPC scan budget exhausted') { - return { - logs: chunkGroups.toReversed().flat(), - foundMint: false, - truncated: true, - }; - } - const message = errorMessage(err); - if (RANGE_TOO_LARGE_ERROR_RE.test(message) && state.chunkSize > MIN_CHUNK_SIZE) { - shrinkForRangeLimit(state, message); - continue; - } - throw err; + const outcome = handleScanChunkError(err, state); + if (outcome === 'truncated') return truncatedScanResult(chunkGroups); + if (outcome === 'retry') continue; } if (chunkStart <= effectiveFloor) break; @@ -204,7 +226,7 @@ export const scanLogsBackward = async ( } return { - logs: chunkGroups.toReversed().flat(), + logs: flattenOldestFirst(chunkGroups), foundMint: false, truncated: Boolean(isMintLog) && budgetRaisedFloor, }; From 63c156a5ab67d08248009a8065cbc22b895a6c8c Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Thu, 13 Aug 2026 15:42:11 +0530 Subject: [PATCH 19/22] refactor: enhance regex patterns for error handling --- src/constants.ts | 41 ++++++++++++-- .../endorsement-chain/fetchEscrowTransfer.ts | 55 ++++++++++++++++++- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 967330a..27ff520 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,12 +1,41 @@ export const DEFAULT_KEY = '4d5a4e3f2f6d2b0a1f2e9b8f8a3c7a0b8d4f5c2e7b1a1c3f2e7b8c2d5a4f7e3e'; -export const INFURA_FREE_TIER_RANGE_RE = - /free tier plan|10\s*block difference|block range should work:\s*\[0x0,\s*0x9\]|Upgrade to PAYG|-32600/i; +// Match JSON-RPC / HTTP codes as standalone tokens (not inside block numbers, gas, chain IDs). +const rpcCode = (code: string): string => `(?:^|[^0-9])${code}(?![0-9])`; -export const RANGE_TOO_LARGE_ERROR_RE = - /query returned more than|too large|block range|10,?000 results|response size|-32012|-32600|10\s*block|free tier|block difference|Upgrade to PAYG|exceeds limit/i; - -export const RATE_LIMIT_ERROR_RE = /429|rate-?limit|too many requests|could not coalesce|-32005/i; +export const INFURA_FREE_TIER_RANGE_RE = new RegExp( + `free tier plan|10\\s*block difference|block range should work:\\s*\\[0x0,\\s*0x9\\]|Upgrade to PAYG|${rpcCode('-32600')}`, + 'i', +); +// reponse extracted from infura logs and also alchemy logs +export const RANGE_TOO_LARGE_ERROR_RE = new RegExp( + [ + 'query returned more than', + 'too large', + 'block range', + '10,?000 results', + 'response size', + 'exceeds limit', + '10\\s*block', + 'free tier', + 'block difference', + 'Upgrade to PAYG', + rpcCode('-32012'), + rpcCode('-32600'), + ].join('|'), + 'i', +); +// response extracted from infura logs and also alchemy logs +export const RATE_LIMIT_ERROR_RE = new RegExp( + [ + `rate-?limit`, + `too many requests`, + `could not coalesce`, + rpcCode('429'), + rpcCode('-32005'), + ].join('|'), + 'i', +); export const INITIAL_CHUNK_SIZE = 10_000; export const FREE_TIER_MAX_CHUNK_SIZE = 10; diff --git a/src/core/endorsement-chain/fetchEscrowTransfer.ts b/src/core/endorsement-chain/fetchEscrowTransfer.ts index dcc3b37..4a6751d 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -32,7 +32,8 @@ const TERMINATION_REASON_LABELS: TerminationReasonLabel[] = [ const toTerminationReasonLabel = (reason: unknown): TerminationReasonLabel | undefined => { const index = Number(reason); - if (!Number.isInteger(index) || index < 0 || index >= TERMINATION_REASON_LABELS.length) { + // Index 0 is TerminationReason.None — omit so shred rows don't expose a fake reason. + if (!Number.isInteger(index) || index <= 0 || index >= TERMINATION_REASON_LABELS.length) { return undefined; } return TERMINATION_REASON_LABELS[index]; @@ -225,8 +226,42 @@ const fetchLogsUnranged = async ( return allLogs.flat() as any; }; +const isNonEmptyCode = (code: unknown): boolean => + typeof code === 'string' && code !== '0x' && code.length > 2; + +// Binary-search the first block where the escrow has code (Title Escrow V5 has no mintBlock). +const resolveContractCreationBlock = async ( + provider: Provider | ethersV6.Provider, + address: string, + latestBlock: number, +): Promise => { + try { + const hasCodeAt = async (block: number): Promise => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return isNonEmptyCode(await (provider as any).getCode(address, block)); + }; + + if (!(await hasCodeAt(latestBlock))) return 0; + // Already present at genesis — cannot bound a useful floor. + if (await hasCodeAt(0)) return 0; + + let low = 0; + let high = latestBlock; + while (low + 1 < high) { + const mid = Math.floor((low + high) / 2); + if (await hasCodeAt(mid)) high = mid; + else low = mid; + } + return high; + } catch { + return 0; + } +}; + const resolveEscrowScanFloor = async ( + provider: Provider | ethersV6.Provider, titleEscrowContract: ethers.Contract | ethersV6.Contract, + titleEscrowAddress: string, latestBlock: number, ): Promise => { try { @@ -238,6 +273,15 @@ const resolveEscrowScanFloor = async ( } catch { // Title Escrow V5 does not expose mintBlock. } + + const creationBlock = await resolveContractCreationBlock( + provider, + titleEscrowAddress, + latestBlock, + ); + if (creationBlock > 0 && creationBlock <= latestBlock) { + return creationBlock; + } return 0; }; @@ -247,7 +291,12 @@ const fetchLogsChunked = async ( titleEscrowAddress: string, ): Promise => { const latestBlock = await provider.getBlockNumber(); - const scanFloor = await resolveEscrowScanFloor(titleEscrowContract, latestBlock); + const scanFloor = await resolveEscrowScanFloor( + provider, + titleEscrowContract, + titleEscrowAddress, + latestBlock, + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const isMintLog = (log: any) => { try { @@ -259,6 +308,8 @@ const fetchLogsChunked = async ( } }; + // When a floor is known (mintBlock or escrow creation), cover the full span to latest. + // Otherwise keep the default backward budget as a last resort. const maxBlocksToScan = scanFloor > 0 ? Math.max(DEFAULT_MAX_BLOCKS_TO_SCAN, latestBlock - scanFloor) From e769239ed71a839a6381fe35f4ef163717fdaaf4 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Fri, 14 Aug 2026 09:16:12 +0530 Subject: [PATCH 20/22] fix: update alias names in CLAUDE.md for consistency with v5 support --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6b7e7fb..a637d82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ in step. **`fetchEndorsementChain()`** is the single public path for Token Registry V4/V5 and Obligation/BoE titles. It auto-detects the escrow contract via `supportsInterface` -(including `supportInterfaceIdsV5.ObligationEscrow` around the obligation check). +(including `v5SupportInterfaceIds.ObligationEscrow` around the obligation check). These public aliases were removed: @@ -116,7 +116,7 @@ These public aliases were removed: | --- | --- | | `fetchObligationEndorsementChain` | `fetchEndorsementChain` | | `fetchEscrowTransfersObligation` | `fetchEscrowTransfersV5` (auto-detects obligation status events) | -| `ObligationEscrowInterface` | `supportInterfaceIdsV5.ObligationEscrow` | +| `ObligationEscrowInterface` | `v5SupportInterfaceIds.ObligationEscrow` | Do **not** re-add the removed aliases. User-facing docs also live in `README.md` (Obligation Registry section). From b9c9e5b134d7783d30745eca96a8329a26731ab0 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Fri, 14 Aug 2026 11:43:52 +0530 Subject: [PATCH 21/22] refactor: implement chunk scanning logic for backward log retrieval in fetchLogsChunked.ts --- src/constants.ts | 4 +- .../endorsement-chain/fetchLogsChunked.ts | 69 ++++++++++++++----- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 27ff520..f79cc31 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,7 +4,7 @@ export const DEFAULT_KEY = '4d5a4e3f2f6d2b0a1f2e9b8f8a3c7a0b8d4f5c2e7b1a1c3f2e7b const rpcCode = (code: string): string => `(?:^|[^0-9])${code}(?![0-9])`; export const INFURA_FREE_TIER_RANGE_RE = new RegExp( - `free tier plan|10\\s*block difference|block range should work:\\s*\\[0x0,\\s*0x9\\]|Upgrade to PAYG|${rpcCode('-32600')}`, + String.raw`free tier plan|10\s*block difference|block range should work:\s*\[0x0,\s*0x9\]|Upgrade to PAYG|${rpcCode('-32600')}`, 'i', ); // reponse extracted from infura logs and also alchemy logs @@ -16,7 +16,7 @@ export const RANGE_TOO_LARGE_ERROR_RE = new RegExp( '10,?000 results', 'response size', 'exceeds limit', - '10\\s*block', + String.raw`10\s*block`, 'free tier', 'block difference', 'Upgrade to PAYG', diff --git a/src/core/endorsement-chain/fetchLogsChunked.ts b/src/core/endorsement-chain/fetchLogsChunked.ts index 7c3749f..c142e99 100644 --- a/src/core/endorsement-chain/fetchLogsChunked.ts +++ b/src/core/endorsement-chain/fetchLogsChunked.ts @@ -169,6 +169,44 @@ function handleScanChunkError(err: unknown, state: AdaptiveScanState): ScanChunk throw err; } +type ScanStepResult = + | { kind: 'done'; result: ScanLogsBackwardResult } + | { kind: 'retry' } + | { kind: 'advance'; nextCursor: number }; + +async function scanOneChunkBackward( + provider: Provider | ethersV6.Provider, + address: string, + cursor: number, + effectiveFloor: number, + state: AdaptiveScanState, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + isMintLog: ((log: any) => boolean) | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chunkGroups: any[][], +): Promise { + if (isBudgetExhausted(state)) { + return { kind: 'done', result: truncatedScanResult(chunkGroups) }; + } + + const chunkStart = Math.max(cursor - state.chunkSize + 1, effectiveFloor); + try { + const chunkLogs = await getLogsRange(provider, address, chunkStart, cursor, state); + if (tryCollectMintSlice(chunkLogs, isMintLog, chunkGroups)) { + return { kind: 'done', result: mintScanResult(chunkGroups) }; + } + chunkGroups.push(chunkLogs); + } catch (err) { + const outcome = handleScanChunkError(err, state); + if (outcome === 'truncated') { + return { kind: 'done', result: truncatedScanResult(chunkGroups) }; + } + return { kind: 'retry' }; + } + + return { kind: 'advance', nextCursor: chunkStart - 1 }; +} + /** * Adaptive backward eth_getLogs scanner. * Starts with a large window, shrinks on provider range limits (including Infura's 10-block @@ -204,25 +242,18 @@ export const scanLogsBackward = async ( let cursor = fromBlock; while (cursor >= effectiveFloor) { - if (isBudgetExhausted(state)) { - return truncatedScanResult(chunkGroups); - } - - const chunkStart = Math.max(cursor - state.chunkSize + 1, effectiveFloor); - try { - const chunkLogs = await getLogsRange(provider, address, chunkStart, cursor, state); - if (tryCollectMintSlice(chunkLogs, isMintLog, chunkGroups)) { - return mintScanResult(chunkGroups); - } - chunkGroups.push(chunkLogs); - } catch (err) { - const outcome = handleScanChunkError(err, state); - if (outcome === 'truncated') return truncatedScanResult(chunkGroups); - if (outcome === 'retry') continue; - } - - if (chunkStart <= effectiveFloor) break; - cursor = chunkStart - 1; + const step = await scanOneChunkBackward( + provider, + address, + cursor, + effectiveFloor, + state, + isMintLog, + chunkGroups, + ); + if (step.kind === 'done') return step.result; + if (step.kind === 'retry') continue; + cursor = step.nextCursor; } return { From f54ee544d20c7b6eeea67c0987ec80b69a1471f5 Mon Sep 17 00:00:00 2001 From: manishdex25 Date: Fri, 14 Aug 2026 11:47:02 +0530 Subject: [PATCH 22/22] refactor: improve regex patterns in constants.ts for better clarity and maintainability --- src/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/constants.ts b/src/constants.ts index f79cc31..b2f5ae0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,7 +2,7 @@ export const DEFAULT_KEY = '4d5a4e3f2f6d2b0a1f2e9b8f8a3c7a0b8d4f5c2e7b1a1c3f2e7b // Match JSON-RPC / HTTP codes as standalone tokens (not inside block numbers, gas, chain IDs). const rpcCode = (code: string): string => `(?:^|[^0-9])${code}(?![0-9])`; - +// use string raw to avoid escaping the backslashes export const INFURA_FREE_TIER_RANGE_RE = new RegExp( String.raw`free tier plan|10\s*block difference|block range should work:\s*\[0x0,\s*0x9\]|Upgrade to PAYG|${rpcCode('-32600')}`, 'i', @@ -16,6 +16,7 @@ export const RANGE_TOO_LARGE_ERROR_RE = new RegExp( '10,?000 results', 'response size', 'exceeds limit', + // use string raw to avoid escaping the backslashes String.raw`10\s*block`, 'free tier', 'block difference',