diff --git a/CLAUDE.md b/CLAUDE.md index e5cc40a..a637d82 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`. | @@ -103,8 +104,40 @@ 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 `v5SupportInterfaceIds.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.** See + [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/README.md b/README.md index c084eb5..8ad1a29 100644 --- a/README.md +++ b/README.md @@ -905,12 +905,19 @@ 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 }) +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/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", 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__/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', 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/constants.ts b/src/constants.ts index 9108745..dc5a385 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1,49 @@ export const DEFAULT_KEY = '4d5a4e3f2f6d2b0a1f2e9b8f8a3c7a0b8d4f5c2e7b1a1c3f2e7b8c2d5a4f7e3e'; + +// Match JSON-RPC / HTTP codes as standalone tokens (not inside block numbers, gas, chain IDs). +const rpcCode = (code: string): string => `(?:^|[^0-9A-Za-z+-])${code}(?![0-9A-Za-z])`; +// 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', +); +// 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', + // use string raw to avoid escaping the backslashes + String.raw`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( + [ + String.raw`rate[\s-]?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; +export const MIN_CHUNK_SIZE = 1; +export const MAX_CHUNK_SIZE = 50_000; +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 0b58669..4a6751d 100644 --- a/src/core/endorsement-chain/fetchEscrowTransfer.ts +++ b/src/core/endorsement-chain/fetchEscrowTransfer.ts @@ -9,9 +9,13 @@ import { TitleEscrow as TitleEscrowV5, 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, + TerminationReasonLabel, TitleEscrowTransferEvent, TokenTransferEvent, TokenTransferEventType, @@ -19,6 +23,22 @@ 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); + // 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]; +}; + export const fetchEscrowTransfersV4 = async ( provider: Provider | ethersV6.Provider, address: string, @@ -44,61 +64,65 @@ export const fetchEscrowTransfersV5 = async ( provider: Provider | ethersV6.Provider, titleEscrowAddress: string, tokenRegistryAddress?: string, + includeObligationStatus?: boolean, ): Promise => { + const isObligationEscrow = + includeObligationStatus ?? (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( + provider, + 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 isContractInterfaceCallException = (err: unknown): boolean => { + 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'; +}; + +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 (err) { + if (isContractInterfaceCallException(err)) return false; + throw err; + } }; 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 => { @@ -115,9 +139,6 @@ const fetchOwnerTransfers = async ( })); }; -/* - Retrieve all events that emits HOLDER_TRANSFER -*/ const fetchHolderTransfers = async ( titleEscrowContract: TitleEscrowV4, ): Promise => { @@ -133,27 +154,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, @@ -161,159 +200,284 @@ 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 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 { + // 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. + } + + const creationBlock = await resolveContractCreationBlock( + provider, + titleEscrowAddress, + latestBlock, ); + if (creationBlock > 0 && creationBlock <= latestBlock) { + return creationBlock; + } + return 0; +}; - if (!tokenRegistryAddress) { - tokenRegistryAddress = await titleEscrowContract.registry(); +const fetchLogsChunked = async ( + provider: Provider | ethersV6.Provider, + titleEscrowContract: ethers.Contract | ethersV6.Contract, + titleEscrowAddress: string, +): Promise => { + const latestBlock = await provider.getBlockNumber(); + const scanFloor = await resolveEscrowScanFloor( + provider, + titleEscrowContract, + titleEscrowAddress, + 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; + } + }; + + // 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) + : DEFAULT_MAX_BLOCKS_TO_SCAN; + + const result = await scanLogsBackward( + provider, + titleEscrowAddress, + latestBlock, + scanFloor, + isMintLog, + maxBlocksToScan, + ); + + 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 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') { - // MINT / RESTORE - const type = identifyTokenReceivedType(event); - return { - type, - from: - type === 'INITIAL' - ? '0x0000000000000000000000000000000000000000' - : tokenRegistryAddress, - to: titleEscrowAddress, - 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, - // Handle ethers v5 and v6 differently - 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', - 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', - 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', - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'Shred') { - return { - type: 'RETURN_TO_ISSUER_ACCEPTED', - blockNumber: event.blockNumber, - from: tokenRegistryAddress, - to: '0x00000000000000000000000000000000000dead', - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } 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', - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'StatusRejected') { - return { - type: 'STATUS_REJECTED', - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } else if (event?.name === 'StatusDischarged') { - return { - type: 'STATUS_DISCHARGED', - blockNumber: event.blockNumber, - transactionHash: event.transactionHash, - transactionIndex: event.transactionIndex, - remark: event.args?.remark, - } as TitleEscrowTransferEvent; - } + 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 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) => mapParsedLogToEvent(event, titleEscrowAddress, tokenRegistryAddress)) .filter((event) => event !== undefined) as (TitleEscrowTransferEvent | TokenTransferEvent)[]; }; 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..c142e99 --- /dev/null +++ b/src/core/endorsement-chain/fetchLogsChunked.ts @@ -0,0 +1,264 @@ +import { ethers as ethersV6 } from 'ethersV6'; +import { Provider } from '@ethersproject/abstract-provider'; +import { + DEFAULT_MAX_BLOCKS_TO_SCAN, + 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)); +} + +interface ScanLogsBackwardResult { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + logs: any[]; + foundMint: boolean; + truncated: boolean; +} + +interface AdaptiveScanState { + chunkSize: number; + maxChunkSize: number; + requestsUsed: number; + deadlineAt: 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 isBudgetExhausted(state: AdaptiveScanState): boolean { + return Date.now() >= state.deadlineAt || state.requestsUsed >= FREE_TIER_MAX_REQUESTS; +} + +async function getLogsRange( + provider: Provider | ethersV6.Provider, + address: string, + fromBlock: number, + toBlock: number, + state: AdaptiveScanState, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + for (let attempt = 0; ; attempt++) { + if (isBudgetExhausted(state)) { + throw new Error('RPC scan budget exhausted'); + } + state.requestsUsed += 1; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (await provider.getLogs({ address, fromBlock, toBlock })) as any[]; + } 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()), + ), + ); + continue; + } + throw err; + } + } +} + +// 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; + 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(); +} + +// 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; +} + +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 + * 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, + 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 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); + const budgetRaisedFloor = effectiveFloor > toBlockFloor; + let cursor = fromBlock; + + while (cursor >= effectiveFloor) { + 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 { + logs: flattenOldestFirst(chunkGroups), + foundMint: false, + truncated: Boolean(isMintLog) && budgetRaisedFloor, + }; +}; diff --git a/src/core/endorsement-chain/helpers.ts b/src/core/endorsement-chain/helpers.ts index ee68312..9ac6c25 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,23 @@ 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 ? { terminationReason } : {}), + }, + ]; } throw new Error('Invalid hash, update your configuration'); @@ -84,17 +114,27 @@ 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 ( [ + 'RETURNED_TO_ISSUER', + 'RETURN_TO_ISSUER_REJECTED', 'STATUS_INITIALIZED', 'STATUS_ACCEPTED', 'STATUS_REJECTED', 'STATUS_DISCHARGED', - 'INITIAL', - 'RETURNED_TO_ISSUER', - 'RETURN_TO_ISSUER_ACCEPTED', - 'RETURN_TO_ISSUER_REJECTED', ].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/retrieveEndorsementChain.ts b/src/core/endorsement-chain/retrieveEndorsementChain.ts index 5e216d2..2855780 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,48 @@ 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 || '', + ...(log.terminationReason ? { 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 + // 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, + holder, + }); 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..c453c51 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 = diff --git a/src/core/endorsement-chain/useEndorsementChain.ts b/src/core/endorsement-chain/useEndorsementChain.ts index 4ffde00..93b5046 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, @@ -196,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, @@ -220,9 +233,10 @@ export const fetchEndorsementChain = async ( versionInterface: TitleEscrowInterface.V5, provider, }), + // ObligationEscrow detection (replaces removed ObligationEscrowInterface alias). isTitleEscrowVersion({ titleEscrowAddress: resolvedTitleEscrowAddress, - versionInterface: ObligationEscrowInterface, + versionInterface: supportInterfaceIdsV5.ObligationEscrow, provider, }), ]); @@ -240,18 +254,12 @@ 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, tokenRegistryAddress, + isObligation, ); transferEvents = mergeTransfersV5(titleEscrowLogs); } 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,