From dbdf509e29e0918d8643c4519e241171fadfa4cd Mon Sep 17 00:00:00 2001 From: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:49:35 +0300 Subject: [PATCH 1/2] Parse decimal and hexadecimal owner token IDs exactly Signed-off-by: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> --- .github/workflows/main.yml | 3 ++ README.md | 5 ++ package-lock.json | 38 +++++++-------- src/errors/step-error.ts | 4 +- src/services/alchemy-client.spec.ts | 2 +- src/services/alchemy.service.spec.ts | 69 ++++++++++++++++++++++++++++ src/services/alchemy.service.ts | 19 ++++++-- src/services/seize/seize.api.spec.ts | 12 +++-- src/services/seize/tdh-info.ts | 1 - yarn.lock | 46 +++++++++---------- 10 files changed, 148 insertions(+), 51 deletions(-) create mode 100644 src/services/alchemy.service.spec.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ad662ae..748273f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,9 @@ name: Allowlist Ci on: + pull_request: + branches: + - main push: branches: - main diff --git a/README.md b/README.md index cde3948..5346683 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,11 @@ lookups. It preserves the v2 NFT endpoint used for historical ownership snapshots. The library does not depend on the archived Alchemy SDK. Applications that inject their own SDK instance continue to own that instance's dependencies. +Historical owner token IDs accept decimal strings and explicitly `0x`-prefixed +hexadecimal strings. Both normalize to exact decimal uint256 IDs without using +JavaScript numbers. Malformed or out-of-range IDs fail instead of being silently +reinterpreted or truncated. + ## Development and release ```sh diff --git a/package-lock.json b/package-lock.json index 81a62b9..c7dde10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2735,36 +2735,36 @@ } }, "node_modules/csv": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/csv/-/csv-6.3.5.tgz", - "integrity": "sha512-Y+KTCAUljtq2JaGP42ZL1bymqlU5BkfnFpZhxRczGFDZox2VXhlRHnG5DRshyUrwQzmCdEiLjSqNldCfm1OVCA==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/csv/-/csv-6.6.3.tgz", + "integrity": "sha512-X2AnOgcxqV+OdLm1M2FOl+bPQrM1LK1jwo4FhEoq8hi8JTR0tQ8ZWv3x3N2gVMIuUmpb4CLrV08rTP49IdFQ8w==", "license": "MIT", "dependencies": { - "csv-generate": "^4.3.0", - "csv-parse": "^5.5.2", - "csv-stringify": "^6.4.4", - "stream-transform": "^3.2.10" + "csv-generate": "^4.6.1", + "csv-parse": "^7.0.2", + "csv-stringify": "^6.8.3", + "stream-transform": "^3.5.1" }, "engines": { "node": ">= 0.1.90" } }, "node_modules/csv-generate": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.3.0.tgz", - "integrity": "sha512-7KdVId/2RgwPIKfWHaHtjBq7I9mgdi8ICzsUyIhP8is6UwpwVGGSC/aPnrZ8/SkgBcCP20lXrdPuP64Irs1VBg==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.6.1.tgz", + "integrity": "sha512-eELl9K716LSSeP2/YcCjch525JztnnERe3jEARWw2v1FN9ukUYfZTNYZ4Rq2Jj/MFKMauffOy9VCaqQTpFThDQ==", "license": "MIT" }, "node_modules/csv-parse": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.2.tgz", - "integrity": "sha512-YRVtvdtUNXZCMyK5zd5Wty1W6dNTpGKdqQd4EQ8tl/c6KW1aMBB1Kg1ppky5FONKmEqGJ/8WjLlTNLPne4ioVA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz", + "integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==", "license": "MIT" }, "node_modules/csv-stringify": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.4.4.tgz", - "integrity": "sha512-NDshLupGa7gp4UG4sSNIqwYJqgSwvds0SvENntxoVoVvTzXcrHvd5gG2MWpbRpSNvk59dlmIe1IwNvSxN4IVmg==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz", + "integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==", "license": "MIT" }, "node_modules/debug": { @@ -6763,9 +6763,9 @@ } }, "node_modules/stream-transform": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.10.tgz", - "integrity": "sha512-Yu+x7zcWbWdyB0Td8dFzHt2JEyD6694CNq2lqh1rbuEBVxPtjb/GZ7xDnZcdYiU5E/RtufM54ClSEOzZDeWguA==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.5.1.tgz", + "integrity": "sha512-TTDX+qKFr7GGRXATn66rprmlFPx08W0UBIccE/rMPgW2sT7GovduZYP4xcdJ7Nu2YigF17U+CNFxYY11+W1oPw==", "license": "MIT" }, "node_modules/string_decoder": { diff --git a/src/errors/step-error.ts b/src/errors/step-error.ts index 4aa67fa..f05626a 100644 --- a/src/errors/step-error.ts +++ b/src/errors/step-error.ts @@ -49,7 +49,9 @@ export function formatStepErrorMetadata( return ''; } - const entries = Object.entries(metadata).filter(([, value]) => value !== undefined); + const entries = Object.entries(metadata).filter( + ([, value]) => value !== undefined, + ); if (!entries.length) { return ''; } diff --git a/src/services/alchemy-client.spec.ts b/src/services/alchemy-client.spec.ts index 61edd32..b60d19c 100644 --- a/src/services/alchemy-client.spec.ts +++ b/src/services/alchemy-client.spec.ts @@ -40,7 +40,7 @@ describe('Alchemy HTTP client', () => { ownerAddresses: [ { ownerAddress: '0xowner1', - tokenBalances: [{ tokenId: '0x0a', balance: '2' }], + tokenBalances: [{ tokenId: '10', balance: '2' }], }, ], pageKey: 'next-page', diff --git a/src/services/alchemy.service.spec.ts b/src/services/alchemy.service.spec.ts new file mode 100644 index 0000000..21ad1ed --- /dev/null +++ b/src/services/alchemy.service.spec.ts @@ -0,0 +1,69 @@ +import { AlchemyClient } from './alchemy-client'; +import { AlchemyService } from './alchemy.service'; + +const UINT256_MAX = + '115792089237316195423570985008687907853269984665640564039457584007913129639935'; + +describe('Alchemy ownership token IDs', () => { + function serviceFor(tokenId: unknown) { + const client: AlchemyClient = { + nft: { + getOwnersForContract: jest.fn().mockResolvedValue({ + owners: [ + { + ownerAddress: '0xowner', + tokenBalances: [{ tokenId, balance: 2 }], + }, + ], + }), + }, + core: { resolveName: jest.fn(), lookupAddress: jest.fn() }, + }; + return new AlchemyService(client); + } + + it.each([ + ['0', '0'], + ['10', '10'], + ['16', '16'], + ['00010', '10'], + ['0x0a', '10'], + ['0X0A', '10'], + ['0x0010', '16'], + ['9007199254740993', '9007199254740993'], + [UINT256_MAX, UINT256_MAX], + [`0x${'f'.repeat(64)}`, UINT256_MAX], + ])('reads %s as token %s without precision loss', async (input, expected) => { + await expect( + serviceFor(input).getCollectionOwnersInBlock({ + contract: '0xcontract', + block: 123, + }), + ).resolves.toEqual([ + { ownerAddress: '0xowner', tokens: [{ tokenId: expected, balance: 2 }] }, + ]); + }); + + it.each([ + '', + ' ', + ' 10', + '10 ', + '-1', + '+10', + '1.5', + '1e2', + '0x', + '0xgg', + 'a', + `${UINT256_MAX}0`, + `0x1${'0'.repeat(64)}`, + null, + undefined, + 10, + ])('rejects malformed or out-of-range token ID %s', async (input) => { + await expect( + serviceFor(input).getCollectionOwnersInBlock({ contract: '0xcontract' }), + ).rejects.toThrow('Invalid Alchemy token ID'); + }); +}); diff --git a/src/services/alchemy.service.ts b/src/services/alchemy.service.ts index 1a664d0..76c048a 100644 --- a/src/services/alchemy.service.ts +++ b/src/services/alchemy.service.ts @@ -4,6 +4,21 @@ import { CollectionOwner } from './collection-owner'; export class AlchemyService { constructor(private readonly alchemy: AlchemyClient) {} + private normalizeTokenId(tokenId: string): string { + if ( + typeof tokenId !== 'string' || + !/^(?:\d+|0x[0-9a-f]+)$/i.test(tokenId) + ) { + throw new Error('Invalid Alchemy token ID'); + } + // REST responses may use decimal IDs; only an explicit 0x prefix means hex. + const value = BigInt(tokenId); + if (value >= BigInt(2) ** BigInt(256)) { + throw new Error('Invalid Alchemy token ID'); + } + return value.toString(); + } + async getCollectionOwnersInBlock({ contract, block, @@ -31,9 +46,7 @@ export class AlchemyService { (owner) => ({ ownerAddress: owner.ownerAddress, tokens: owner.tokenBalances.map((token) => ({ - tokenId: BigInt( - `0x${token.tokenId.replace('0x', '').substring(0, 64)}`, - ).toString(), + tokenId: this.normalizeTokenId(token.tokenId), balance: +token.balance, })), }), diff --git a/src/services/seize/seize.api.spec.ts b/src/services/seize/seize.api.spec.ts index ae0c089..8860820 100644 --- a/src/services/seize/seize.api.spec.ts +++ b/src/services/seize/seize.api.spec.ts @@ -216,8 +216,12 @@ describe('Seize API Uploads', () => { }); it('should retry upload download across gateway priority list with normal urls', async () => { - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const logSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); try { const tdhInfos = await seizeApi.getUploadsForBlock(17531454); @@ -264,7 +268,9 @@ describe('Seize API Uploads', () => { }); it('should normalize raw upload urls back to normal gateway urls', async () => { - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const logSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); try { const tdhInfos = await seizeApi.getUploadsForBlock(17531455); diff --git a/src/services/seize/tdh-info.ts b/src/services/seize/tdh-info.ts index 770bdfe..69e9eab 100644 --- a/src/services/seize/tdh-info.ts +++ b/src/services/seize/tdh-info.ts @@ -1,4 +1,3 @@ - export interface CommonTdhInfo { readonly consolidation_key: string; readonly consolidation_display: string; diff --git a/yarn.lock b/yarn.lock index 82ab2e6..e5671fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1569,30 +1569,30 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -csv-generate@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/csv-generate/-/csv-generate-4.3.0.tgz" - integrity sha512-7KdVId/2RgwPIKfWHaHtjBq7I9mgdi8ICzsUyIhP8is6UwpwVGGSC/aPnrZ8/SkgBcCP20lXrdPuP64Irs1VBg== +csv-generate@^4.6.1: + version "4.6.1" + resolved "https://registry.npmjs.org/csv-generate/-/csv-generate-4.6.1.tgz" + integrity sha512-eELl9K716LSSeP2/YcCjch525JztnnERe3jEARWw2v1FN9ukUYfZTNYZ4Rq2Jj/MFKMauffOy9VCaqQTpFThDQ== -csv-parse@^5.5.2: - version "5.5.2" - resolved "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.2.tgz" - integrity sha512-YRVtvdtUNXZCMyK5zd5Wty1W6dNTpGKdqQd4EQ8tl/c6KW1aMBB1Kg1ppky5FONKmEqGJ/8WjLlTNLPne4ioVA== +csv-parse@^7.0.2: + version "7.0.2" + resolved "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz" + integrity sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA== -csv-stringify@^6.4.4: - version "6.4.4" - resolved "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.4.4.tgz" - integrity sha512-NDshLupGa7gp4UG4sSNIqwYJqgSwvds0SvENntxoVoVvTzXcrHvd5gG2MWpbRpSNvk59dlmIe1IwNvSxN4IVmg== +csv-stringify@^6.8.3: + version "6.8.3" + resolved "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz" + integrity sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA== csv@^6.3.1: - version "6.3.5" - resolved "https://registry.npmjs.org/csv/-/csv-6.3.5.tgz" - integrity sha512-Y+KTCAUljtq2JaGP42ZL1bymqlU5BkfnFpZhxRczGFDZox2VXhlRHnG5DRshyUrwQzmCdEiLjSqNldCfm1OVCA== + version "6.6.3" + resolved "https://registry.npmjs.org/csv/-/csv-6.6.3.tgz" + integrity sha512-X2AnOgcxqV+OdLm1M2FOl+bPQrM1LK1jwo4FhEoq8hi8JTR0tQ8ZWv3x3N2gVMIuUmpb4CLrV08rTP49IdFQ8w== dependencies: - csv-generate "^4.3.0" - csv-parse "^5.5.2" - csv-stringify "^6.4.4" - stream-transform "^3.2.10" + csv-generate "^4.6.1" + csv-parse "^7.0.2" + csv-stringify "^6.8.3" + stream-transform "^3.5.1" debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@4: version "4.3.4" @@ -3713,10 +3713,10 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" -stream-transform@^3.2.10: - version "3.2.10" - resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.10.tgz" - integrity sha512-Yu+x7zcWbWdyB0Td8dFzHt2JEyD6694CNq2lqh1rbuEBVxPtjb/GZ7xDnZcdYiU5E/RtufM54ClSEOzZDeWguA== +stream-transform@^3.5.1: + version "3.5.1" + resolved "https://registry.npmjs.org/stream-transform/-/stream-transform-3.5.1.tgz" + integrity sha512-TTDX+qKFr7GGRXATn66rprmlFPx08W0UBIccE/rMPgW2sT7GovduZYP4xcdJ7Nu2YigF17U+CNFxYY11+W1oPw== string_decoder@^1.1.1: version "1.3.0" From e4471e8c5f3a9caea1aa702516ff2282f0f3e9fb Mon Sep 17 00:00:00 2001 From: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:54:47 +0300 Subject: [PATCH 2/2] Clarify runtime token ID validation and uint256 limit Signed-off-by: GelatoGenesis <1407802+GelatoGenesis@users.noreply.github.com> --- src/services/alchemy.service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/alchemy.service.ts b/src/services/alchemy.service.ts index 76c048a..3e632a1 100644 --- a/src/services/alchemy.service.ts +++ b/src/services/alchemy.service.ts @@ -1,10 +1,12 @@ import { AlchemyClient, AlchemyOwnersOptions } from './alchemy-client'; import { CollectionOwner } from './collection-owner'; +const UINT256_LIMIT = BigInt(2) ** BigInt(256); + export class AlchemyService { constructor(private readonly alchemy: AlchemyClient) {} - private normalizeTokenId(tokenId: string): string { + private normalizeTokenId(tokenId: unknown): string { if ( typeof tokenId !== 'string' || !/^(?:\d+|0x[0-9a-f]+)$/i.test(tokenId) @@ -13,7 +15,7 @@ export class AlchemyService { } // REST responses may use decimal IDs; only an explicit 0x prefix means hex. const value = BigInt(tokenId); - if (value >= BigInt(2) ** BigInt(256)) { + if (value >= UINT256_LIMIT) { throw new Error('Invalid Alchemy token ID'); } return value.toString();